285 Commits

Author SHA1 Message Date
Johnny
0262707ef0 Merge pull request #1296 from itflow-org/develop
Develop to master for 26.08.2 Release
2026-08-10 12:10:21 -04:00
johnnyq
41900191af Bump ITFlow version and update Changelog 2026-08-10 11:33:38 -04:00
johnnyq
076f45abb0 Remove Stale SMTP IMAP host if switching Standard SMTP.IMAP to OAUTH 2026-08-09 23:59:54 -04:00
johnnyq
f7548af234 Fix Export Missing coluns due to the select star sweep 2026-08-09 14:33:06 -04:00
johnnyq
de46184a5b Fix calendar queries missing columns lost in the SELECT * sweep 2026-08-09 13:41:01 -04:00
johnnyq
f237993fd7 DB Structure update for AI temperature 2026-08-09 13:29:46 -04:00
johnnyq
a96324628a Fix broken cron page due to the select * sweep, note thi sdid not break cron itself 2026-08-09 13:27:51 -04:00
Johnny
89b080b430 Merge pull request #1294 from itflow-org/develop
Develop to Master for point release
2026-08-08 14:15:55 -04:00
johnnyq
b903ee02fe Bump version 2026-08-08 13:15:24 -04:00
johnnyq
6ff454134b Update changelog 2026-08-08 13:14:25 -04:00
johnnyq
04991a0d79 Fix ticket schedule cancellation never cancelling the calendar event 2026-08-08 13:00:01 -04:00
johnnyq
46a63b191c Fix bulk recurring ticket priority change denying access to all non-admin users 2026-08-08 12:59:31 -04:00
johnnyq
6b65c8150f Fix contact delete leaving an orphaned portal user and anonymize not redacting the phone number 2026-08-08 12:58:46 -04:00
johnnyq
d484629ebc do not update ticket status in history if it is the same 2026-08-08 12:41:34 -04:00
johnnyq
656aa95a4e Fix WebUI setup regression locking out after first step 2026-08-07 18:09:01 -04:00
johnnyq
db96766ab0 Fix Error on Scheduling Tickets, Fixed Zapcal Path 2026-08-07 12:44:56 -04:00
johnnyq
10f0c9de58 remove audit system time script 2026-08-06 19:26:43 -04:00
johnnyq
ac598f6379 Fix system-generated ticket replies booking fake time worked 2026-08-06 19:25:39 -04:00
johnnyq
605c7811e4 Fix AI 2026-08-06 19:18:34 -04:00
johnnyq
942fe41b18 Apply further gating to client pdf export 2026-08-06 19:03:47 -04:00
johnnyq
64423cbefa Fix Client PDF Export 2026-08-06 18:51:09 -04:00
johnnyq
3cae52067a Fix calendar event Delete bug 2026-08-06 14:21:57 -04:00
johnnyq
abb724b40b Removed Orphaned vars 2026-08-06 14:03:44 -04:00
johnnyq
f8899e592f More Select * Queries convert 2026-08-06 13:57:46 -04:00
johnnyq
108781db5b Move more select * to column select for further optimization 2026-08-06 13:44:02 -04:00
johnnyq
b3744e9ed5 More sweeps to replace select * with actual returned column names dramatically reduces php memory usage and speeds up processing dramatically especially in the crons since they run all the time 2026-08-06 13:10:23 -04:00
johnnyq
b8c9d5b4cf Query Optimization: Select only needed columns instead of SELECT * on looped queries 2026-08-06 12:59:43 -04:00
johnnyq
77defcf7d8 Global Search Query Optimization only returns columns needed 2026-08-06 12:30:27 -04:00
johnnyq
9324d0822e Drop more dead client left joins big saver is the badge counts 2026-08-06 12:14:43 -04:00
johnnyq
eb2cc3d653 Update Contributing with the new clientScopeSql function 2026-08-06 11:55:39 -04:00
johnnyq
ddd9c2b56a clientScopeSql sweep whicn now removed the extra unessesary client joins 2026-08-06 11:45:56 -04:00
johnnyq
83383599a1 Add clientScopeSql helper for client-scoped list queries 2026-08-06 11:25:11 -04:00
johnnyq
49a3c2d266 Enforce client scoping on API expense reads and record updates
Fixes (expenses/read.php missing apiClientScopeSql), plus an unscoped UPDATE in contacts/update.php and follow-up writes firing on zero-row updates in assets/credentials.
2026-08-06 11:00:49 -04:00
Johnny
ccaa45b0ae Merge pull request #1292 from itflow-org/develop
Develop to Master for26.08 Release
2026-08-04 15:29:17 -04:00
johnnyq
cbf8922f5b Update Changelog 2026-08-04 15:13:57 -04:00
johnnyq
b39f3c0b23 Redesing task bar and remove redundant task count in ticket details 2026-08-04 12:40:47 -04:00
johnnyq
f9ae7598ec Bump FullCalendar from 7.0.0 to 7.0.2 2026-08-04 12:39:04 -04:00
johnnyq
261bdab61a Gate SLA option in ticket details its gated everywhere else 2026-08-04 12:14:56 -04:00
johnnyq
1b386567fa Bump DataTables from 2.3.7 to 3.0.1 2026-08-04 12:12:42 -04:00
johnnyq
5eba64afad Bump TinyMCE to 8.8.2 2026-08-04 12:05:23 -04:00
johnnyq
c30c12674a Sync DB Seed data between setup and setup cli 2026-08-03 22:41:31 -04:00
wrongecho
8aaf5b450f Merge branch 'develop' of https://github.com/itflow-org/itflow into develop 2026-08-03 20:55:57 +01:00
wrongecho
d74c9743be Feature: Add invoice_items API endpoint (adding line items to an invoice) 2026-08-03 20:53:53 +01:00
johnnyq
0311a3d056 Fix remaing undefine vars for audits and flash messages 2026-08-03 15:40:44 -04:00
johnnyq
c5288f9e30 Fix undefined variables in expense, asset and contact audit/flash messages 2026-08-03 15:34:18 -04:00
johnnyq
e800064202 Fix Expense Description logging spelling 2026-08-03 15:28:36 -04:00
johnnyq
abd79c0aed Feature: Add Income Bulk Actions (Account, Payment Method and category 2026-08-03 15:23:50 -04:00
wrongecho
a313278623 Merge branch 'develop' of https://github.com/itflow-org/itflow into develop 2026-08-03 18:58:58 +01:00
wrongecho
f87d43881e Add very basic product import via CSV 2026-08-03 18:58:53 +01:00
johnnyq
ea3d6a2fc6 Merge branch 'develop' of github.com:itflow-org/itflow into develop 2026-08-03 13:28:53 -04:00
johnnyq
7a44cdd438 Fix Autofill products in invoice, quotes, recurring invoice, tax field wasnt updating and a dash was being put in front 2026-08-03 13:28:41 -04:00
wrongecho
ad234c5e46 Merge branch 'develop' of https://github.com/itflow-org/itflow into develop 2026-08-03 18:01:47 +01:00
wrongecho
f16e7893ac Bump supported version 2026-08-03 18:01:40 +01:00
johnnyq
3b0d3839ae Merge branch 'develop' of github.com:itflow-org/itflow into develop 2026-08-03 12:55:24 -04:00
johnnyq
ffe32adbd3 Move categories and tag types to a left side nav instead of top nav 2026-08-03 12:54:33 -04:00
wrongecho
5ee1c9580f Enhance .htaccess rules 2026-08-03 17:51:04 +01:00
johnnyq
315768d651 Add Category column and filter into income along with the exports 2026-08-02 18:23:18 -04:00
johnnyq
6b5be0f881 Rework Update UI and fix banch 2026-08-02 18:07:24 -04:00
johnnyq
a27019511e Remove unused overdue invoice setting move master cron switch out of notificaiton and into cron 2026-08-02 17:22:28 -04:00
johnnyq
ad15fabbb3 Credential length guard 2026-08-02 16:30:14 -04:00
johnnyq
e793804203 Migrate credential password from varbinary to varchar and set max length for passwords 2026-08-02 16:18:36 -04:00
johnnyq
89fba247c5 Add some missing maxlength form options to prevent info overflow and result in error 500 2026-08-02 16:08:01 -04:00
johnnyq
3e532fc792 Update imapengine dependancies too 2026-08-02 01:26:40 -04:00
johnnyq
cf4446f405 Bump imapengine from 1.25.3 to 1.25.4 2026-08-02 01:21:29 -04:00
johnnyq
ab41543578 Remove unused DB wrappers 2026-08-01 19:49:22 -04:00
johnnyq
c8dab1a7e5 Revert PAyment cleanInput too 2026-08-01 18:48:07 -04:00
johnnyq
056db263a5 Rever old test run of using prepared mysql statement for client add /edit 2026-08-01 18:20:43 -04:00
johnnyq
6107d9f833 Update Contributing 2026-08-01 15:42:56 -04:00
johnnyq
23a09f54fc Reworked getFieldById to remove escaping and and reworked everything that uses the function to also escape, was causing double escaping in many places this standardizes the function 2026-08-01 15:33:48 -04:00
johnnyq
75028ba09a Fix some Ticket History gaps 2026-08-01 14:33:37 -04:00
johnnyq
cdb4a43b89 Major UI/UX overhaul on Tickets listing / Kanban and ticket details 2026-08-01 14:12:03 -04:00
johnnyq
4fec57709a Recurring Expense: Fix edit not keeping client 2026-08-01 12:09:39 -04:00
johnnyq
abe02d7d8e Fix Wording on changelog 2026-07-31 16:57:19 -04:00
johnnyq
53f45fa008 Fix queued backups never building, per-type retention, and cron connection handling 2026-07-31 16:35:39 -04:00
johnnyq
ae468d6cee Encrypted backups with types, scheduling and CLI restore
Backups are now AES-256 encrypted zips in three types (full, database
only, master key), catalogued in a new backups table, built by cron rather
than the web request, and kept under uploads/backups with retention in the
nightly job. The encryption key is one value per install held in config.php,
never in the database and never in the file name.

Restore is shared by the setup wizard and the new scripts/restore_cli.php,
which is the only path without an upload size limit. It verifies the key and
unpacks the archive before dropping anything, and dumps the current database
first so a failed import is rolled back.

A backup dumps, zips and encrypts for minutes without issuing a query, so on
a server with a short wait_timeout the connection is closed underneath it and
the UPDATE marking the backup complete is what fails - long after the archive
was written correctly. The connection is now held open for the job and
re-established before any write that follows long file work, including the
database phase of a restore. Retention recovers rows a dropped connection
left behind: still Running after six hours becomes Complete if the archive is
on disk, Failed if it is not.

cron.php's own failure path is hardened to match. It recorded job failures
through the same connection the failing job had just killed, so an uncaught
exception ended the dispatch and no trace of the original error survived.
Failures now also echo to stdout, so cron mails something useful when the
database is unreachable.

Security: the setup wizard's restore step is now closed on any install that
has users, whatever config.php says. $config_enable_setup defaulted to
enabled when the flag was absent, and the flag is only written at the end of
a successful install, so an install abandoned partway left an unauthenticated
endpoint that would drop every table, import an attacker-supplied archive,
and overwrite uploads/ including the .htaccess that stops PHP running there.
Affects 26.07 and earlier. Restoring over a live install is now CLI only.
2026-07-31 16:18:20 -04:00
johnnyq
6a5cf6704a Add Indexes to foreign key in ticket replies and attachments fixes slow ticket access issue with many replies with large inline images 2026-07-31 14:16:19 -04:00
johnnyq
16000efcf7 Drop SVG extension in the ticket mail parser 2026-07-31 12:39:10 -04:00
johnnyq
293ba534e2 Simplified Changelog 2026-07-30 19:18:05 -04:00
johnnyq
7c5579677d Cron: daily jobs catch up when the dispatcher is invoked before their scheduled time, so an old single daily crontab entry still runs them 2026-07-30 18:21:47 -04:00
johnnyq
09a74d6d26 Update changelog and Important Upgrade Steps 2026-07-30 18:01:35 -04:00
johnnyq
8436cd6296 Nightly tasks: apply late fees, overdue reminder emails, and autopay retries at most once per invoice per day, and lock nightly to the daily schedule 2026-07-30 17:50:11 -04:00
johnnyq
36c9c030c0 DB Update 2026-07-30 17:07:38 -04:00
johnnyq
0631c45040 Fix Cron Schedule and new directory includes created inside cron for the cron lock 2026-07-30 17:06:07 -04:00
johnnyq
119334ab71 Cron Fix 2026-07-30 16:44:56 -04:00
johnnyq
38e8edea19 Cron runs all jobs from a single dispatcher cron.php which should now be run every minute and all other cron jobs eliminated from cron 2026-07-30 13:48:39 -04:00
johnnyq
1005b73416 DB Structure Update 2026-07-30 02:30:15 -04:00
johnnyq
f2d5ac4a29 Feature: Calendars are now exportable shareable so Third Party Calendar services can read them, also added recoccurence to calendar, and all day along with sperating the time fields and click in a box to add calendar event is possible 2026-07-30 02:18:30 -04:00
johnnyq
3c8f812a16 Feature: On all export modals added Filter Tab and Selectable Columns tab with default selected, also you can now export to a PDF 2026-07-30 00:46:15 -04:00
johnnyq
729d22d19b Move Attachments to Assignment in new ticket get rid of help texts in reply for attachments and update changelog 2026-07-29 18:39:43 -04:00
johnnyq
2395a3ee16 Phase 2 of ticket attachements by agents 2026-07-29 18:10:34 -04:00
johnnyq
7c8c93c01c Feature allow agents to attach files to tickets in the app 2026-07-29 17:58:04 -04:00
johnnyq
e74307eaea Feature: Add Dynamic Task editing in add ticket and add recurring ticket, recurring tickets now have their own task table as well 2026-07-29 17:48:41 -04:00
johnnyq
74a5359b80 Move Watchers into assignment in add ticket 2026-07-29 17:28:57 -04:00
johnnyq
d01024931b Retire v1 Ticket and merge into 1 add ticket 2026-07-29 17:24:55 -04:00
johnnyq
8a092852cf Fixed Asset section in recurring ticket if global added project in add ticket 2026-07-29 16:52:47 -04:00
johnnyq
3296e48ac6 Unlink template from recurring ticket if template is deleted 2026-07-29 16:36:47 -04:00
johnnyq
eaf3f5abd1 Add Badge next to recurring tickets with a template 2026-07-29 16:28:17 -04:00
johnnyq
75d8b4d37c Fix get FieldByID spots 2026-07-29 16:16:32 -04:00
johnnyq
7245ff0650 Fix Cron URL Key on recurring tickets 2026-07-29 16:11:36 -04:00
johnnyq
bc64c9e3d1 Feature: Abiliry to assign Ticket Templates to Recurring Tickets 2026-07-29 15:55:14 -04:00
johnnyq
9c123c42d1 Update Changelog 2026-07-29 14:34:59 -04:00
johnnyq
af9990ff13 Fix Contact Notes and Fix a few broken links 2026-07-29 14:22:18 -04:00
johnnyq
a900b2cd74 Update DB Structure 2026-07-29 14:15:15 -04:00
johnnyq
fc5cdeea82 Add UI Elements for Asset Notes similar to contact notes 2026-07-29 14:13:55 -04:00
johnnyq
bb1f0d5489 SLA Fixes 2026-07-29 13:50:00 -04:00
johnnyq
566d74c15d Fix SLA resolve-time reporting, stale settings cache on save, and report N+1 2026-07-29 13:34:34 -04:00
johnnyq
750c2e58cc Update DB Structure 2026-07-29 13:23:02 -04:00
johnnyq
9533da4053 Repair SLA verdicts missed by kanban/portal resolve; record first response on kanban resolve 2026-07-29 13:21:37 -04:00
johnnyq
cc651c18be DB Structure Update 2026-07-29 13:04:05 -04:00
johnnyq
162a597da9 Add SLA pausing, SLA reports, SLA filtering and kanban SLA state (phase 3)
Statuses can be flagged to pause the resolution clock; sla_history records the intervals a ticket's clock actually ran and the deadline is re-based on remaining budget when it resumes. Adds SLA Summary and SLA by Client reports, an SLA state filter on the ticket list, SLA colouring on kanban cards, and an Urgent column on the Tickets by Client report. DB update 2.5.1. Also fixes resolution SLA verdicts being skipped when resolving via kanban or the client portal.
2026-07-29 13:02:27 -04:00
johnnyq
e76b384606 Add ticket reply API endpoints
GET /api/v1/ticket_replies/read.php and POST create.php, so an RMM or
monitoring system can append to a ticket it did not open.

Replies default to Internal so an integration cannot email a client by
omitting a parameter. Public replies mark first response, notify the
contact and watchers, and fire the same custom actions as the agent
reply handler. An optional ticket_status also sets the status, and
resolves the ticket and marks the resolution SLA when set to 4.

Replies are always joined to tickets so ticket_client_id is checked
against the key user's client scope, and the client_id named on a write
must match the ticket's own client. The reply is attributed to the user
the API key runs as.

read.php resolves ticket_reply_by_name, since that column holds a
user_id on Internal and Public replies but a contact_id on Client ones.
2026-07-29 01:53:18 -04:00
johnnyq
07c73a6a04 Add Urgent Priority, move Per Client SLA Settings to notes tab in client add / edit modal remove unnessesary sla admin setting for client overide 2026-07-29 00:49:31 -04:00
johnnyq
d5ed05017b Allow selecting client SLA assignments on client create/edit 2026-07-29 00:21:13 -04:00
johnnyq
dd45a0f4f1 Update DB Structure 2026-07-29 00:03:08 -04:00
johnnyq
d08ef6184a Add optional ticket SLAs
Response/resolution targets stamped at creation from per-client/priority assignments, business-hours due date math, warn/breach alert stages via cron/ticket_sla.php, ticket list coloring, per-ticket SLA override, admin page for plans/assignments/business hours. DB update 2.5.0. No behavior change unless SLAs are assigned. Bulk reply now only counts Public replies as first response.
2026-07-29 00:00:42 -04:00
johnnyq
78ee38b39f Revert DB Update 2026-07-28 22:08:01 -04:00
johnnyq
1494d2cb5e Backed out of Refunds which still keeps the fix to properly set the invoice status when payment is deleted 2026-07-28 22:03:35 -04:00
johnnyq
31921ee99b Add Refunding capability along with Stripe Refunding 2026-07-28 21:34:06 -04:00
johnnyq
b3f959ac55 Use Short echo tags across the code 2026-07-28 18:47:52 -04:00
johnnyq
0031438bce Update Contrubuting 2026-07-28 18:12:42 -04:00
johnnyq
556ab22c79 Enfoce Sales Permission Read on products export 2026-07-28 17:53:13 -04:00
johnnyq
78e7e1c49e Remove Normalize Scripts 2026-07-28 17:46:39 -04:00
johnnyq
c5ff3e2a3f Normalize line endings to LF; add .gitattributes and .editorconfig 2026-07-28 17:45:52 -04:00
johnnyq
7f7880a4a7 Add Quick Payments view modal in Invoices 2026-07-28 12:49:57 -04:00
johnnyq
5f3a0bec46 Remove old payments and revenues and all ties to the pages as these are combined in income now 2026-07-28 12:38:29 -04:00
johnnyq
c3896ba3d6 Add Export CSV to the new combined income page 2026-07-28 12:26:50 -04:00
johnnyq
7327ebb37c Stop parallel login attempts from bypassing the rate limits 2026-07-28 01:01:43 -04:00
johnnyq
2e855f62c9 Require credential module access to view or share credentials 2026-07-28 00:40:33 -04:00
johnnyq
4c65b8c561 Clear mail bodies after successful delivery 2026-07-28 00:18:07 -04:00
johnnyq
c64c76c577 Rotate session ID on login and fix client portal Entra SSO 2026-07-27 23:49:09 -04:00
johnnyq
22e93589cb Fix share link view accounting and quote response handling 2026-07-27 21:11:45 -04:00
johnnyq
f129311b2e Remove patch and update gitignore 2026-07-27 21:02:13 -04:00
johnnyq
70d165e8bc Claim shared item views atomically and log guest audit IPs 2026-07-27 20:58:37 -04:00
johnnyq
b555a300bc Give the vault session key full entropy and guard empty-key credential writes
generateUserSessionKey() built its AES-128 key with randomString(16), which
draws 12 random bytes and base64url-encodes them into 16 printable characters.
Those 16 characters were handed to openssl_encrypt as the 16-byte key, so the
key that wraps the site master key in the user's session carried 96 bits of
entropy instead of 128. Same for the IV.

The key and IV are now random_bytes(16) and stay raw for the cipher. Base64 is
applied only for transport - the cookie and the session copy of the IV - and
decoded again at the three read sites.

Sessions issued before this change will not decode and are not supported;
anyone logged in at deploy time is prompted to log in again. Nothing is stored
long term in this path, so there is nothing to migrate.

Also guards encryptCredentialEntry against a master key that failed to decrypt.
openssl_encrypt accepts false as a key and silently encrypts under an empty
key, so a session whose cookie had expired could write a credential that no
key would ever recover. It now returns false instead. This was reachable
before this change too, whenever a cookie expired mid-session.
2026-07-27 20:27:23 -04:00
johnnyq
829527244e Fix broken ajax 2026-07-27 20:25:48 -04:00
johnnyq
50352fb779 Revert vault session key entropy change 2026-07-27 20:13:46 -04:00
johnnyq
8dcb45fc9d Remove Patch 2026-07-27 20:00:42 -04:00
johnnyq
b18544c69b Give the vault session key full entropy and guard empty-key credential writes
generateUserSessionKey() built its AES-128 key with randomString(16), which
draws 12 random bytes and base64url-encodes them into 16 printable characters.
Those 16 characters were handed to openssl_encrypt as the 16-byte key, so the
key that wraps the site master key in the user's session carried 96 bits of
entropy instead of 128. Same for the IV.

The key and IV are now random_bytes(16) and stay raw for the cipher. Base64 is
applied only for transport - the cookie and the session copy of the IV - and
decoded again at the three read sites.

Sessions issued before this change will not decode and are not supported;
anyone logged in at deploy time is prompted to log in again. Nothing is stored
long term in this path, so there is nothing to migrate.

Also guards encryptCredentialEntry against a master key that failed to decrypt.
openssl_encrypt accepts false as a key and silently encrypts under an empty
key, so a session whose cookie had expired could write a credential that no
key would ever recover. It now returns false instead. This was reachable
before this change too, whenever a cookie expired mid-session.
2026-07-27 20:00:09 -04:00
johnnyq
8825661360 Dump new DB Structure 2026-07-27 18:38:48 -04:00
johnnyq
a4fdced10f Rate limit 2FA code attempts and narrow the TOTP acceptance window 2026-07-27 18:36:43 -04:00
johnnyq
97490bef3b Security Fix in recurring invoices in cron 2026-07-27 18:07:15 -04:00
johnnyq
a844d7b428 Adds a shared flock guard used by all five cron entry points, keyed per
script and per install. Replaces the mail queue's own lock file, which
was not atomic and could be deleted out from under a long run. Bounds
the SMTP conversation so an unresponsive server cannot hold the lock.
Recovers rows left at Sending by a run that died, which nothing
previously picked up.
2026-07-27 17:35:39 -04:00
johnnyq
686fca99e1 Fix Possible duplicate emails in a race condition in mail queue 2026-07-27 16:59:10 -04:00
johnnyq
775e8f58f6 Fix Cents Calc 2026-07-27 16:55:16 -04:00
johnnyq
409d769eb7 Scope the cron lock per install so instances don't block each other
Report an unopenable cron lock file instead of claiming cron is running
2026-07-27 16:41:21 -04:00
johnnyq
cff416dd87 Remove Patch 2026-07-27 15:22:46 -04:00
johnnyq
65de8c35e6 Prevent duplicate Stripe payment bookings and overlapping cron runs 2026-07-27 15:22:16 -04:00
johnnyq
815802fb7c Gate Admin UI Modals to Admin 2026-07-27 14:50:32 -04:00
johnnyq
53842a50d2 Add URI Tabs to mail settings keeps the tab active upon saving 2026-07-26 01:52:25 -04:00
johnnyq
5fc2f93b38 Allow negative numbers in add expense 2026-07-26 01:40:11 -04:00
johnnyq
32be252261 Remove patch file 2026-07-26 01:11:27 -04:00
johnnyq
04014da21f Combined Payments and Revenues as one View called income keeps all income in one place and still can add revenue not related to invoice 2026-07-26 01:10:52 -04:00
johnnyq
276e84ad0b Expenses: Set Current Date in new expense 2026-07-25 20:38:58 -04:00
johnnyq
cd34236716 Fix saved-payment setup Checkout: embedded_page ui_mode + attach existing Stripe customer 2026-07-25 19:00:06 -04:00
johnnyq
a26ac2c4fc Fixed adding saved credit cards in stipe due to API change 2026-07-25 18:47:13 -04:00
johnnyq
497ea3b669 Centralize client portal access checks through contactCan()
Replaces the duplicated primary/billing/technical checks across portal pages, post.php handlers, nav, and dashboard with contactCan()/enforceContactCan(). Same behavior, but the rules now live in one place instead of being copy-pasted, which is what let them drift before. file.php keeps its 404 response; ticket-visibility and approval-routing checks are intentionally left as-is.
2026-07-25 17:55:30 -04:00
johnnyq
887063394a Prevent client portal contacts from editing their own contact record 2026-07-25 17:28:57 -04:00
johnnyq
04bf3c234f Read API credential decrypt password from body instead of query string
Keeps the credential decryption secret out of the URL, so it no longer lands in web-server access logs, proxy logs, or browser history. credentials/read.php now reads api_key_decrypt_password from the request body, matching create/update. Consumers reading decrypted credentials must send it in the body, not the query string.
2026-07-25 17:08:22 -04:00
johnnyq
e6daf00528 Fix some broken API calls after RBAC update 2026-07-25 16:59:03 -04:00
johnnyq
c509b7f693 Add user-based RBAC for API keys
API keys now run as a user and inherit that user's module, operation, and client permissions. Drops per-key client scoping and removes existing keys (must be recreated). Adds an edit modal to change a key's user.
2026-07-25 16:50:58 -04:00
johnnyq
8d46e2a7db Fix Client Permission overides to include deny allow list in projects prohect and edit ticket 2026-07-25 13:28:47 -04:00
johnnyq
3756561458 Feature: Add Deny Client Access to Client Access Permssions - BREAKS UI, MUST UPDATE DB USING php scripts/cli_update.php --db_update 2026-07-25 13:22:52 -04:00
johnnyq
ff4b69b511 Client Enforcement via function on client edit instad of in sql query 2026-07-25 00:35:47 -04:00
johnnyq
761b2a7af4 Module permissions modal enforcement sweep to match its post counterpart 2026-07-25 00:28:42 -04:00
johnnyq
7a793cbbcc Add more enforceClientAccess to agent ajax gets 2026-07-24 22:51:57 -04:00
johnnyq
113ef4b9a4 Add more enforceClientAccess 2026-07-24 22:49:18 -04:00
johnnyq
3524010d7a Add enforceClientAccess to 4 bulk modals initially missing 2026-07-24 22:06:42 -04:00
johnnyq
8993bce53f Force No Indexes of empty directories even if enabled using .htaccess in uploads 2026-07-24 20:52:35 -04:00
johnnyq
8813b2c066 Use imapEngine lib for testing imap connection 2026-07-24 18:56:16 -04:00
johnnyq
5c4805b822 Neutralize CSV formula injection in generated exports using the new created escapeCsvFormula Function 2026-07-24 18:12:45 -04:00
johnnyq
28f1a965b8 Remove dead code 2026-07-24 17:33:09 -04:00
johnnyq
565aff35f6 Set SameSite=Lax on the session cookie
Applied at all seven session_start sites. keepalive.php and guest_post.php
had no cookie flags at all and now set httponly and secure too.
2026-07-24 14:09:52 -04:00
johnnyq
3549c2ef2a Harden CSRF handling and session cookies
Return 403 on CSRF failure instead of a 302, which anything following
redirects records as a 200. Set SameSite=Lax at every session_start;
keepalive.php and guest_post.php had no cookie flags at all.
2026-07-24 14:06:04 -04:00
johnnyq
8afc5aea89 Typo fix 2026-07-24 13:45:52 -04:00
johnnyq
af51c4316f Updated validateCSRFToken to automatically grab and compare get or post csrf token no longer need to pass an argument to the function which fixed the issue if no hash was present 2026-07-24 13:40:37 -04:00
johnnyq
dd358da65d Update missing CSRF endpoint checks missed update_db 2026-07-24 13:22:17 -04:00
johnnyq
91a1676e89 Update missing CSRF endpoint checks 2026-07-24 13:16:51 -04:00
johnnyq
e7698f15bd Make better use of formatAddress function for clients listing 2026-07-24 12:22:15 -04:00
johnnyq
c7b34fe107 Remove leftover getFallBack in client pdf export in favor of ?: '-' 2026-07-23 18:29:11 -04:00
johnnyq
5846119ec9 remove duplicate formatAddress 2026-07-23 18:16:57 -04:00
johnnyq
41b118ab87 Add formatAddress function and replace all addresses with it 2026-07-23 18:10:39 -04:00
johnnyq
9c65644adc Update the rest of the PHP functions to use camelCase 2026-07-23 17:39:06 -04:00
johnnyq
3d94846a61 Fix broken link to service modal in services 2026-07-23 13:48:00 -04:00
johnnyq
90bc8ed029 Fix broken links to modals and remove _details from service 2026-07-23 13:43:04 -04:00
johnnyq
40be80981d Fix vendor modal link in service details 2026-07-23 13:34:08 -04:00
johnnyq
b853c24c96 Remove a few more unused vendor contact modals and update the escaping in rename modal vendor_details.php to vendor.php 2026-07-23 13:26:06 -04:00
johnnyq
5f26d75e18 Update Functions in ticket_edit_vendor and document link vendor and deleted legacy code unused that had legacy functions tied to them 2026-07-23 13:17:41 -04:00
johnnyq
8f43b92496 Use stripe_init include to init stripe and updated code everywhere 2026-07-23 13:00:01 -04:00
johnnyq
7ccdc942fe Bump imapEngine from v1.25.2 to v1.25.3 2026-07-23 00:59:07 -04:00
johnnyq
2b756f6ea4 Split DB Updates into seperate files, with the cutoff being 2.0.0 2026-07-22 18:43:11 -04:00
johnnyq
17e4c61067 Spelling Fix 2026-07-22 17:40:30 -04:00
johnnyq
fad62ca045 Remove custom_hosting tables 2026-07-22 17:36:55 -04:00
johnnyq
5402578ce6 Remove Static payment processing fees from database and update field hints with useful info when adding / editing payment provider 2026-07-22 17:32:01 -04:00
johnnyq
8ee780566e Cleanup Expense Code in post blocks and remove single use function since stripe payments get reconciled during nightly cron 2026-07-22 17:06:10 -04:00
johnnyq
08334b22c3 Add Stripe fee reconciliation to cron
Balance transactions aren't always attached when a payment completes,
so the fee expense can be skipped at payment time. Daily cron pass now
finds recent Stripe payments with no matching fee expense and records
the actual fee once available. Dedupes by expense reference prefix,
30-day lookback.
2026-07-22 16:47:28 -04:00
johnnyq
f4b1b6585b Move Expense block for Stripe after intent 2026-07-22 16:16:40 -04:00
johnnyq
66b38b7f19 Get Expense from Stripe instead of Static Entry 2026-07-22 16:07:39 -04:00
johnnyq
54e2005224 Icon change for Mail Queue 2026-07-21 14:18:33 -04:00
johnnyq
d5a1305192 Reorg Admin Side nav menu 2026-07-21 14:17:16 -04:00
johnnyq
4bb497ea9c Reorganize Client Side nav and the renamed Client overview to All Client Docs side nav 2026-07-21 13:43:56 -04:00
johnnyq
97ab1df762 Feature: Add Transactions, reorg Main side nav 2026-07-21 13:21:23 -04:00
johnnyq
058e31c290 Fix Truncated Client name in side nav needs to be truncated before escaped 2026-07-20 19:31:31 -04:00
johnnyq
d173357b51 Add Bulk and Single Refresh certificate Actions 2026-07-20 19:14:38 -04:00
johnnyq
e134c89c3a Add Bulk and Single Refresh Domain Actions 2026-07-20 18:54:01 -04:00
johnnyq
a35ef025dc Add Expiring in Filter Field to assets, licenses, domains and certificate and wire that into the dashboard as well 2026-07-20 18:25:44 -04:00
johnnyq
ce0a62b0c3 Add Expiring Asset Warranties and Liceneses to the Dashboard 2026-07-20 17:45:03 -04:00
johnnyq
1a3d7a1e0d Bump ImapEngine from 1.25.1 to 1.25.2 2026-07-18 11:17:53 -04:00
johnnyq
9cc7e5ff3c Move Guest Function formatBytes to root shared functions and use in files and files section in contact 2026-07-16 20:25:18 -04:00
johnnyq
113d6220bb Add back missing function call 2026-07-16 19:32:52 -04:00
johnnyq
e40926c08d Restore native DNS/RDAP domain lookups (lost in functions.php split)
getDnsRecords and getDomainExpirationDate had regressed to shell_exec
(dig/whois) when domain code was moved into functions/domain.php; the
RDAP helper stack was dropped entirely. Restores the dns_get_record +
RDAP + port-43-socket-whois implementation from d1e1609b under the
current function names. getSslCertificate was unaffected.
2026-07-16 19:30:38 -04:00
johnnyq
e11d8f3252 Harden checkFileUpload: drop content hashing for random storage names
Replace md5(file_contents)+randomString(2) naming with randomString(32).
No longer reads the file into memory (removes file_get_contents), so
validation is O(1) regardless of size or upload count. Add is_uploaded_file()
and UPLOAD_ERR_OK checks, use pathinfo() for extension extraction, and
return false consistently on all failures (oversize previously returned a
truthy error string that callers treated as a valid filename).
2026-07-16 19:18:08 -04:00
johnnyq
41d029937a More Client Scoping fixes 2026-07-16 14:29:23 -04:00
johnnyq
1251944dcc More Client Enforcement in modals across the app 2026-07-16 13:33:17 -04:00
johnnyq
2c09ce097c More Client Enforcement in modals across the app 2026-07-16 13:10:02 -04:00
johnnyq
9e84ce4c07 Ticket Merge: EnforceClientAccess on modal and ticket list 2026-07-16 12:14:36 -04:00
johnnyq
a8ad16758c Update Contributing 2026-07-16 00:44:51 -04:00
johnnyq
9f11af0d4d Update Contributing 2026-07-16 00:42:10 -04:00
johnnyq
906f58473d Fix guest view credential totp 2026-07-16 00:26:25 -04:00
johnnyq
e39ecb7ac9 Remove Legacy OTP code and fix guest view credential 2026-07-15 22:02:52 -04:00
johnnyq
86b0e11e95 Enforce ClientAccess in asset modal 2026-07-15 21:37:48 -04:00
johnnyq
2076bb2a93 Added Audit Log on Edit Credential Modal as password can be viewed here 2026-07-15 21:13:07 -04:00
johnnyq
7c9bd77603 Remove unused credential view model, add enforceClient Check on export client pdf 2026-07-15 21:09:11 -04:00
johnnyq
b1c57fc5c7 Feature: Add new js function to retrieve and Audit log Password reveals, still need to get the edit modals 2026-07-15 20:32:50 -04:00
johnnyq
89cb9074ab UI: Add Monospace font to timestamps in notifications listing 2026-07-15 18:59:05 -04:00
johnnyq
590bad3bcc Global Search: use secure file handler on files in global search 2026-07-15 18:55:40 -04:00
johnnyq
60bae12024 Clean up admin post handler to match agent
- Anchor includes and glob to __DIR__ so loading doesn't depend on CWD
- Replace _model.php regex check with str_ends_with()
2026-07-15 18:48:40 -04:00
johnnyq
e48ea57db9 Clean up agent post handler
- Remove dead referer/module parsing (unused since modules are glob-loaded)
- Anchor includes and glob to __DIR__ so loading doesn't depend on CWD
- Replace _model.php regex check with str_ends_with()
2026-07-15 18:45:27 -04:00
johnnyq
3838d052f0 Add secure file handler for client ticket attachment and file view along with client and contact permission isolation, add the handler links in asset and contact 2026-07-15 18:35:32 -04:00
johnnyq
c8f616ebee Ticket UI: Migrated ticket Attachments to a small table class 2026-07-15 13:52:52 -04:00
johnnyq
33a2929ab6 Feature: Secure File Download Handler for ticket attachments Implemented 2026-07-15 13:42:19 -04:00
johnnyq
df3a24eab7 Feature: Secure File Download Handler Implemented 2026-07-15 13:11:02 -04:00
johnnyq
6dbe84dee4 Update admin/ breadcrumbs to match plural file names 2026-07-15 00:48:16 -04:00
johnnyq
6e20800a98 Update Tax to Tax Rate in headr title for tax rates 2026-07-15 00:44:32 -04:00
johnnyq
dfedc31c04 Update Tax to Tax Rates in admin side nav 2026-07-15 00:43:09 -04:00
johnnyq
dae7f09650 Remove _details, fix plural and singular file naming in admin, update admin post to match agent post 2026-07-15 00:38:34 -04:00
johnnyq
38a042d435 Remove _details and use singular filename for certain objects 2026-07-15 00:12:27 -04:00
johnnyq
c3a438ee59 Replace the rest of the getFallBack Functions with simple ?: '-' 2026-07-14 19:17:26 -04:00
johnnyq
bcb244c245 Update skipped function renames in anything named vendor 2026-07-14 19:09:32 -04:00
johnnyq
cddcedbf1c Cleanup format Functions 2026-07-14 18:28:53 -04:00
johnnyq
eac913eb07 Remove function removeEmoji() as this is no longer needed as the DB Charset is set to the standard utf8mb4 across the board 2026-07-14 18:27:02 -04:00
johnnyq
ee7ef71855 Correct plugins to libs path for zapCal library in functions 2026-07-14 18:15:32 -04:00
johnnyq
2181e92e44 Removed valueOrDash Function was able to replace with ?: '-' 2026-07-14 18:13:34 -04:00
johnnyq
746e919aff Replace Function getFallBack with valueOrDash() 2026-07-14 18:00:12 -04:00
johnnyq
71f7299337 Replace Function roundToNearest15() with roundToNearest15Min() 2026-07-14 17:55:59 -04:00
johnnyq
21a23e79b0 Replace Function getSSL() with getSslCertificate() 2026-07-14 17:54:31 -04:00
johnnyq
52b5063066 Replace Function getDomainRecords() with getDnsRecords() 2026-07-14 17:53:16 -04:00
johnnyq
8f824a1868 Replace Function shortenClient() with shortenClientName() 2026-07-14 17:46:25 -04:00
johnnyq
9d540f4bfe Replace Function customAction() with triggerCustomAction() 2026-07-14 17:43:55 -04:00
johnnyq
b8873039a4 Rename Functions: flash_alert, sanitizeFilename and display_folder_options using camelCase instead to match other custom php functions 2026-07-14 17:41:41 -04:00
johnnyq
f1836c70cd Replace Function logAction() with logAudit() 2026-07-14 17:35:23 -04:00
johnnyq
29a0b3ead8 Replace Function fetchUpdates() with checkForUpdates() 2026-07-14 17:31:54 -04:00
johnnyq
31cc3f3ac3 Replace Function strtoAZaz09() with toAlphanumeric() 2026-07-14 17:30:13 -04:00
johnnyq
8de3cd300d Replace Function key32gen() with generateTotpSecret() 2026-07-14 17:27:02 -04:00
johnnyq
b57ddc0e5c Replace Function sanitizeInput() with just escapeSql() and update all instances throughout 2026-07-14 17:17:50 -04:00
johnnyq
7bc47a58fe Replace Function nullable_htmlentities() with just escapeHtml() and update all instances throughout 2026-07-14 17:10:45 -04:00
johnnyq
d62b6e2ae7 Update comment 2026-07-14 16:58:16 -04:00
johnnyq
5eb9f6b6d5 Fix weak RNG in key32gen (TOTP secret generation)
Replace srand()/rand() with random_int() for cryptographically secure,
unbiased key generation. The previous implementation seeded rand() from
microtime(), making TOTP secrets predictable if the generation time
could be approximated. Also removes modulo bias and dead while(1)
wrapper.

Output format is unchanged: 32 chars from the base32 alphabet (A-Z,
2-7), so existing TOTP enrollments are unaffected.
2026-07-14 16:57:16 -04:00
johnnyq
bf0d799caf Remove old function comment 2026-07-14 16:51:34 -04:00
johnnyq
29bea9517d Removed the last of legacy validate functions and replaced with the new enforce fumctions. 2026-07-14 16:49:14 -04:00
johnnyq
a2a96f9752 Removed unused legacy functions and split the functions into their own file and use function.php to call them 2026-07-14 16:32:45 -04:00
johnnyq
c1447f5bad Bump TinyMCE from 8.6.0 to 8.7.0 2026-07-14 16:08:05 -04:00
johnnyq
79032de032 Certificates: Allow to search by description 2026-07-14 11:23:54 -04:00
johnnyq
b81e57db28 Show Invoice Stats in Invoice only for user permissable clients 2026-07-13 17:19:32 -04:00
johnnyq
95441dc3bb Show 7 Characters of client name in client side nav instead of Abbreviation 2026-07-13 13:29:24 -04:00
johnnyq
fe7e9b2398 Main Side Nav: Only shows counts to the user who has permission to see 2026-07-13 13:08:03 -04:00
johnnyq
91e9f6097b Client Overview Side Nav: Only shows counts to the user who has permission to see 2026-07-13 12:45:59 -04:00
johnnyq
0f0aa89f75 Bump ImapEngine from 1.25.0 to 1.25.1 2026-07-10 18:37:16 -04:00
johnnyq
8da3a107fb Rename plugins to libs and update all file references 2026-07-10 13:24:20 -04:00
johnnyq
7ba1571400 Revert Billable placement in tickets, create CONTRIBUTING.md file 2026-07-10 12:32:46 -04:00
johnnyq
d2026a417f Add Client to top of the modals trips, ticket, recurring ticket, invoice, recurring invoice, quote and rework the UI for Ticket/Recurring ticket create Put Billable beside subject so it doesnt take up a whole row 2026-07-09 18:50:31 -04:00
johnnyq
fc59481476 Enforce Client Access in client selectbox 2026-07-09 16:17:00 -04:00
johnnyq
21328c2dee clientEnforcement on Recurring Invoices and payments listings 2026-07-09 16:13:39 -04:00
johnnyq
317fe91359 Add Missing clientEnforcement in various modals, already handled on POST also add client selection in edit trip and enforce client permissions on trips 2026-07-09 16:07:54 -04:00
johnnyq
68f705b135 Set EnforceClientAccess Perms throughout expenses, modals and post along with listing 2026-07-09 14:51:51 -04:00
johnnyq
c01645c4e2 Expense: On create enforce client Access only if Client is selected 2026-07-08 18:13:14 -04:00
johnnyq
ae5c9fe8ec Mail Parser: Add logic when creating ITFlow folder to determine if the namespace is INBOX. or can be created in root directory. Fixes issue with CPanel Dovecot Maildir++ configuration where their namespace is just INBOX 2026-07-08 15:58:00 -04:00
johnnyq
de6d2b1595 OAUTH Send Quote Fix: was reading smtp host var needed to read smtp provider as smtp host is not filled in when OAUTH2 is selected 2026-07-07 16:20:21 -04:00
johnnyq
47a5825d3e OAUTH Send Invoice Fix: was reading smtp host var needed to read smtp provider as smtp host is not filled in when OAUTH2 is selected 2026-07-07 15:04:48 -04:00
johnnyq
78c3dd0eed Remove Dig and Whois binary requirements, revert fetchUpdates function 2026-07-05 16:05:19 -04:00
johnnyq
d1e1609b8a Right — same commit or separate, here's the combined version covering both:
Remove exec/shell_exec from update checker and domain lookups

Update checker (fetchUpdates) no longer shells out to git:

- Current commit read directly from .git/HEAD, following the branch
  ref through loose refs with a packed-refs fallback (survives git gc);
  detached HEAD handled
- Latest commit fetched from the GitHub API via curl using the
  Accept: application/vnd.github.sha header (returns bare SHA, no
  JSON parsing)
- Repo owner/name derived from the origin remote in .git/config so
  forks check against their own remote, falling back to
  itflow-org/itflow
- Failures now distinguish unreadable .git (permissions) from API
  errors (network/rate limit) instead of silently returning empty

Domain lookups no longer shell out to dig and whois:

- DNS records (A/NS/MX/TXT) via dns_get_record()
- Registration data via RDAP (JSON over HTTPS, curl), the ICANN
  successor to port-43 whois; RDAP server per TLD resolved from
  IANA's bootstrap registry, cached locally for a week, rdap.org as
  secondary lookup
- Expiration date from RDAP's structured expiration event, replacing
  regex/date-format guessing for RDAP-covered TLDs
- Port-43 whois retained as socket-based fallback (fsockopen) for
  ccTLDs without RDAP, with IANA server discovery and one registrar
  referral follow
- RDAP responses cached per-run: getDomainRecords() and
  getDomainExpirationDate() on the same domain = one HTTP request

Fixes whois rate limiting, removes the exec dependency for hardened
hosts (Snuffleupagus etc.), and eliminates the shell injection
surface - no shell, nothing to escape.
2026-07-05 15:33:51 -04:00
Johnny
698135d53d Merge pull request #1291 from itflow-org/develop
Develop to Master for Stable
2026-07-03 12:43:04 -04:00
johnnyq
a32c4b5172 Update Changelog 2026-07-03 12:29:10 -04:00
johnnyq
c34e4f5ff0 EnforceClientAccess on Generating Guest Share Links 2026-07-03 12:27:46 -04:00
johnnyq
aab8b34970 Bump ITFlow Version and changelog 2026-07-03 12:15:05 -04:00
johnnyq
1a1f6d80a1 Possible Fix for OAUTH2 M365 2026-07-02 16:08:59 -04:00
4234 changed files with 62411 additions and 42552 deletions

20
.editorconfig Normal file
View File

@@ -0,0 +1,20 @@
# Editor defaults for ITFlow - see CONTRIBUTING.md ("Style")
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
[*.md]
trim_trailing_whitespace = false
# Vendored - do not reformat
[libs/**]
indent_style = unset
indent_size = unset
trim_trailing_whitespace = false
insert_final_newline = false

44
.gitattributes vendored Normal file
View File

@@ -0,0 +1,44 @@
# ITFlow line-ending policy
#
# Everything ITFlow ships is LF in the repository and LF in the working tree.
# Contributors on Windows get LF too - this is deliberate. ITFlow is deployed
# to Linux/Apache and edited over sftp/ssh as often as it is cloned, so a
# checkout must be byte-identical everywhere.
* text=auto eol=lf
# Explicit for the file types we author, so nothing depends on git's guess.
*.php text eol=lf
*.js text eol=lf
*.css text eol=lf
*.html text eol=lf
*.sql text eol=lf
*.md text eol=lf
*.json text eol=lf
*.yml text eol=lf
*.xsd text eol=lf
*.svg text eol=lf
*.txt text eol=lf
*.ini text eol=lf
.htaccess text eol=lf
# Binary assets: never touched, never diffed as text.
*.png binary
*.gif binary
*.jpg binary
*.jpeg binary
*.webp binary
*.ico binary
*.icc binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary
*.crt binary
*.ser binary
*.z binary
# Vendored third-party code is preserved byte-for-byte as shipped upstream.
# Per CONTRIBUTING.md libs/ is never edited in place - it is replaced wholesale -
# so normalizing it here would create spurious diffs on the next library update.
libs/** -text

6
.gitignore vendored
View File

@@ -20,6 +20,10 @@ uploads/users/*
!uploads/users/index.php
uploads/tmp/*
!uploads/tmp/index.php
!uploads/tmp/.htaccess
uploads/backups/*
!uploads/backups/index.php
!uploads/backups/.htaccess
uploads/tickets/*
!uploads/tickets/index.php
uploads/ticket_templates/*
@@ -49,3 +53,5 @@ setup/custom/*
api/v1/custom/*
!api/v1/custom/readme.php
.zed
*.patch

View File

@@ -1,2 +1,2 @@
# Prevent access to .git, .github, and config.php
RedirectMatch 404 ^/(\.git|\.github|config\.php)
RedirectMatch 401 ^/(\.git|\.github|config\.php)

View File

@@ -2,6 +2,219 @@
This file documents all notable changes made to ITFlow.
## [26.08.2] Maint Release
### Upgrading to 26.08.2
Update the files from Settings > Update as normal. There is no database change in this release, so nothing else is required.
### Bug Fixes
- Calendar: fixed the agent calendar showing no events.
- Calendar: shared calendar feeds set to publish busy blocks only were publishing full event titles, locations and descriptions to anyone holding the subscription link.
- Cron: fixed Maintenance > Cron failing to load. Scheduled jobs themselves were unaffected and kept running.
- Exports: restored the missing columns on the ticket, quote, recurring invoice, software and user exports.
- API: restored the full record on the credentials list endpoint.
- Mail: switching an existing install from Standard SMTP/IMAP to Microsoft 365 or Google OAuth no longer leaves the old mail server behind, which stopped sending and ticket email fetching from working. The connection settings for OAuth providers are now fixed by the provider and cleared on save.
## [26.08.1] Maint Release
### Upgrading to 26.08.1
Update the files from Settings > Update as normal. This release moves the database to 2.6.7 and the web updater completes it for you — the command line step that 26.08 required is not needed again.
### Breaking Changes and Notes
- Client access: agents with restricted client access now see records that have no client assigned. Previously this varied by page — unassigned tickets and projects were visible, unassigned expenses and credentials were not. It is now consistent everywhere.
### Bug Fixes
- Setup: fixed the wizard closing itself after the first user, which left new 26.08 installs stuck in a redirect loop between `/setup` and `/login.php`.
- API: tightened client scoping on the expense read and record update endpoints.
- Income: revenue rows now respect restricted client access.
- Client PDF Export: fixed the export producing a CSV file, and each section is now gated on the module that owns it.
- AI: fixed model creation, per-use-case model selection, configurable temperature, and error reporting.
- Ticket: system-generated replies no longer record time worked that was never worked.
- Ticket: fixed an error when scheduling a ticket.
- Ticket: cancelling a schedule now cancels the calendar event on the recipient's calendar.
- Ticket: history no longer records a status change when the status did not change.
- Recurring Ticket: bulk priority changes no longer deny access to agents who are not administrators.
- Contact: deleting a contact now removes the linked portal user, and anonymizing now redacts the phone number.
- Calendar: fixed event deletion.
### New Features & Updates
- Performance: queries now select only the columns they use instead of `SELECT *`, cutting memory use and query time across the app and especially in the crons.
- Performance: removed client joins that were only there for scoping — side nav badge counts are significantly faster.
- Client scoping: added a `clientScopeSql()` helper so list queries scope on the owning column instead of a joined `clients.client_id`.
- Contributing: documented the column-selection and client-scoping conventions.
## [26.08]
### Upgrading to 26.08
> **Read this before you start.** Done out of order this update will break your instance. The database structure changes, every API key is deleted, and the whole cron setup is replaced.
1. **Back everything up.** Take a full VM backup or snapshot before you start.
2. **Remove every ITFlow line from your crontab** (or delete `/etc/cron.d/itflow`). The old per-minute jobs must not keep firing against a half-updated install. You put the new one in at step 5.
3. **Update the files with the normal web updater**, from Settings > Update. It will pull the new files, report the update as successful, and then error out as it drops you back into the app. That is normal — the new code is now running against the old database.
4. **Run the database update from the command line.** Just this once it cannot be done from the web interface. Run it as the user that owns the ITFlow files — the script tells you which user if you get it wrong:
```bash
sudo -u www-data php /path/to/itflow/scripts/update_cli.php --update_db
```
It applies every pending version in order and reports each one as it goes. On an install with a lot of ticket history it can take a minute or more, so let it finish. If a step fails it stops there without advancing the recorded version, so you can fix the problem and run it again. The 500s stop as soon as it completes.
5. **Add the new cron entry.** One line runs everything now, and the schedules are managed in ITFlow under Settings > Cron:
```
* * * * * www-data php /path/to/itflow/cron/cron.php >/dev/null
```
Drop the `www-data` column if this goes in a user crontab rather than `/etc/cron.d`.
6. **Recreate your API keys.** Every existing key is deleted by this update. Issue new ones and update anything that talks to the ITFlow API.
7. **Check it took.** Open Settings > Cron — the green "Cron last checked in" banner should appear within a couple of minutes and every job should pick up a schedule.
Only this release needs the command line for the database update. Normal updates go back to running from Settings > Update as usual.
### Breaking Changes and Notes
- Cron: the crontab collapses to a single entry. `cron/cron.php` is now a dispatcher that runs every minute and works out which jobs in `cron/` are due, and the old nightly work has moved to `cron/nightly_tasks.php` which it runs at 03:00.
- Cron: an existing crontab keeps working — the per-minute scripts still run and still lock correctly, and a single daily `cron.php` entry still runs the daily jobs — but any job added in this or a future release only runs once the dispatcher is scheduled.
- Cron: ticket SLAs need no entry of their own. `cron/ticket_sla.php` is in the dispatcher's job list and runs every minute once the new entry is in place. Without it SLA targets are still worked out and displayed, but warnings and breaches never fire.
- Backups: write down your backup encryption key. It is generated on first use and stored in `config.php`, never in the database, and without it a backup cannot be restored.
- API: every existing key is deleted by this update and must be recreated. Keys are now owned by a user and inherit that user's role, module and client permissions instead of carrying their own client scope, and existing keys cannot be safely mapped to a user.
- API: credential decrypt passwords are now read from the request body instead of the query string. Any caller passing that value in the URL needs updating.
- Client access permissions now support deny rules as well as allow, and the permissions UI will not load until the database update has run.
- Business hours are new and default to Monday to Friday, 09:00 to 17:00 in your configured timezone. SLA targets are measured against them, so set them before assigning SLAs.
- The `plugins` directory is now `libs`. Anything pointing at `plugins/` directly — custom scripts, reverse proxy rules, web server config — needs updating.
- Several pages dropped the `_details` suffix and moved to consistent singular and plural filenames, so old bookmarks and external links will 404.
- Credential passwords moved from `varbinary` to `varchar(500)` and now have a length guard. Existing credentials are migrated by the database update.
### New Features & Updates
- Backups are now encrypted, catalogued, schedulable and restorable from the command line. Three types — Full (database and uploads), Database Only, and Master Key — and every archive is an AES-256 encrypted zip. Open them with 7-Zip, WinZip, PeaZip or Keka; `unzip`, Windows Explorer and the macOS Archive Utility do not support AES.
- Backups: one encryption key per install, generated on first use and stored in `config.php`, never in the database and never in the file name. It is shown in Maintenance > Backup.
- Backups are built by cron rather than by your browser. The button queues the work and the dispatcher picks it up within the minute, then notifies you — a dump of a real install takes longer than a web request is allowed to live, which is why the old Download Backup button timed out on large instances.
- Backups: scheduled backups are a new `backup` cron job, off by default, turned on in Maintenance > Cron. Retention by age and by count runs in the nightly job and never deletes the newest backup.
- Backups are stored outside the web-served path under `uploads/backups/` with a deny-all rule and downloaded through an admin-only handler. Set `$config_backup_path` in `config.php` to keep them off the web root entirely.
- Backups: restore from the command line with `php scripts/restore_cli.php --file=/path/to/backup.zip`. This is the only restore path with no size limit — the setup wizard's restore is capped by PHP's upload limits and a full backup is usually larger. `--inspect` checks an archive without changing anything.
- Backups: restores validate before they destroy. The key is checked and the archive unpacked before any table is dropped, and the current database is dumped first and put back automatically if the import fails.
- Cron: one entry instead of five, and a page to manage it. Jobs are tracked in a new `cron_jobs` table, so a job whose slot was missed runs at the next opportunity rather than waiting a day, and each job locks for its own run so a slow mailbox or a long nightly pass no longer holds anything else up.
- Cron: new Settings > Cron page listing every job with its schedule, last run, duration, outcome and next due time. Jobs can be disabled, rescheduled, or run on demand — Run Now hands the job to the next dispatch so it starts within a minute and still runs on the command line. The last error is kept until dismissed rather than vanishing behind the next success, and the page says plainly when the crontab entry itself is missing.
- Cron: the nightly run is safe to repeat. Late fees, overdue invoice reminders and autopay retries now apply at most once per invoice per day, so a Run Now after the scheduled pass no longer stacks fees or re-emails clients. Nightly Tasks only accepts the daily schedule.
- Cron: the master enable switch moved out of Notifications and into the Cron settings where it belongs, and the unused overdue invoice setting has been removed.
- Ticket: major UI overhaul of the ticket list, the kanban board and the ticket detail page.
- Ticket: redesigned the task bar on ticket details and removed the redundant task count.
- Ticket SLAs, optional throughout. An SLA sets a response target and an optional resolution target, assigned per client and priority with a global default and an explicit "no SLA" override. Targets are measured against your business hours. Tickets show time remaining and turn yellow at a configurable warning threshold and red on breach, on both the ticket list and the kanban board, and can be filtered by SLA state. Nominated statuses pause the resolution clock for "waiting on customer", preserving the remaining budget. Two new reports, SLA Summary and SLA by Client. With no assignments defined nothing behaves any differently.
- Ticket: added an Urgent priority.
- Ticket: agents can attach files to tickets from inside the app, both when raising a ticket and on a reply, and attachments are emailed to the contact through the mail queue. A 10 MB ceiling applies per message; anything that does not fit stays on the ticket to download.
- Ticket: tasks can be added and edited inline in the add ticket and add recurring ticket modals.
- Ticket: the older add ticket modal has been retired, there is one add ticket modal now.
- Ticket: watchers and attachments have moved into the assignment section of the add ticket modal.
- Ticket: recurring tickets can be assigned a ticket template. Picking one fills in the subject and details and stamps the template's task list onto every ticket the schedule raises, from the nightly run and a forced run alike. The recurring ticket list shows which schedules carry a template and how many tasks it adds.
- Ticket: recurring tickets now own their task list. The template fills it in when picked but it can then be edited per schedule, and it is those edits the run reads. Existing schedules are backfilled from their template by the database update.
- API: added ticket reply endpoints for creating and reading replies.
- API: added an invoice_items endpoint for adding line items to an invoice.
- Calendar: calendars can be published as a read-only iCalendar (ICS) subscription feed and read by Google Calendar, Nextcloud, Apple Calendar, Thunderbird or anything else that takes a feed URL. The link carries a secret key and needs no login, can be regenerated or revoked at any time, and a busy only option publishes time blocks without titles, descriptions or locations. Refresh timing belongs to the subscribing client — Google refreshes on its own schedule and cannot be forced, and Nextcloud defaults to weekly and refuses feed URLs resolving to private IPs.
- Calendar: events can be marked all day, and the date and time are now separate fields. Previously all day was inferred from a midnight start, which made a genuine midnight appointment indistinguishable from an all-day event. Existing events are backfilled by the database update using the old rule, so nothing changes appearance.
- Calendar: repeating events now work. The Repeat field was present but disabled and the stored value was never drawn. It is now selectable daily, weekly, monthly or yearly, and monthly and yearly series skip dates that do not exist in a period rather than sliding into the next month. Recurrence is series-wide — editing any occurrence edits the whole series, and individual occurrences cannot yet be moved or cancelled. Repeating events are marked with an icon and a hover note, and the delete action reads Delete series and asks for confirmation.
- Calendar: clicking empty space creates an event there. Clicking a day or a time slot, or dragging across several, opens the New Event modal with the start and end already filled in and the All day switch set to match. A range dragged out or lengthened by hand is no longer overwritten by the end-time-follows-start behaviour.
- Exports: every export modal now has a Filter tab and a Selectable Columns tab with sensible defaults, and can export to PDF as well as CSV.
- Combined Payments and Revenues into a single Income page with CSV export. Revenue not tied to an invoice is still added there and payments are still added from invoices. The standalone Payments and Revenues pages are gone.
- Income: added a Category column and filter, carried through to the exports.
- Income: added bulk actions for account, payment method and category.
- New Transactions page — a per-account ledger of transfers, revenues, payments and expenses with filtering by type, category, client, payment method, amount range and date, a running balance, summary cards, account balances in the account picker, and CSV export.
- Products: added a basic product import via CSV.
- Added user based RBAC for API keys, so a key runs as a user and inherits that user's permissions.
- Added deny rules to client access permissions, so access can be granted broadly and revoked for specific clients.
- New secure file download handler for files and ticket attachments, with client and contact permission isolation on the client portal.
- Invoices: clicking the Paid or Partial status badge opens a read-only breakdown of the payments recorded against that invoice.
- Assets: multiple notes per asset, same as contact notes, with categorized note types (Maintenance, Repair, Configuration, Upgrade, Inspection, Note).
- Reworked the Maintenance > Update page, and fixed the branch handling on it.
- Categories and tag types moved from a top button nav to a left side nav.
- Dashboard: added expiring asset warranties and licenses, along with an "Expiring in" filter for assets, licenses, domains and certificates.
- Added bulk and single refresh actions for domains and certificates.
- Stripe gateway fees now come from the actual Stripe balance transaction rather than a static percentage and flat fee configured in ITFlow, with a nightly pass to backfill fees that were not available at payment time. The static fee fields are gone from payment provider settings.
- Database updates are now split into per-version files under `admin/database_updates/`, the latest version is derived from the directory listing, and one run applies everything pending. Migration history before 2.0.0 has been pruned.
- Reorganized the main, client, admin and reports side navigation menus.
- Mail settings tabs are now URL addressable and stay on the active tab after saving.
- Removed the legacy vendor contacts feature.
- `dig` and `whois` are no longer required, domain lookups use native DNS and RDAP.
- Tightened the `.htaccess` rules, and added one for `uploads/tmp`.
- Bumped the minimum supported PHP version.
### Security
- Rate limited 2FA code attempts and narrowed the TOTP acceptance window.
- Rotate the session ID on login to prevent session fixation.
- Stopped parallel login attempts from bypassing the login rate limits.
- Tightened validation on the recurring invoice frequency used by the billing run.
- Admin UI modals are now gated to admins. Previously any logged-in user could open them directly and read stored payment provider and AI provider API keys — rotate those keys when you update, as there is no record of who may have viewed them.
- Global search returned credentials to users without credential module access. It is now gated like every other credential surface.
- Credential password reveals are now written to the audit log, on both the reveal endpoint and the TOTP code.
- Swept module and client permission enforcement across modals and ajax endpoints to match the post handlers, closing a number of cases where a user restricted to certain clients could read another client's records by ID.
- Products: the CSV export now requires sales module read access.
- Neutralized CSV formula injection in generated exports.
- Fixed weak random number generation in TOTP secret generation.
- Shared item views are now claimed atomically so the view limit cannot be exceeded by simultaneous requests, and guest audit IPs are logged.
- Hardened CSRF handling and session cookies, and set `SameSite=Lax` on the session cookie.
- Hardened file upload handling to use random storage names.
- Client Portal: contacts can no longer edit their own contact record.
- The setup wizard's restore step is now closed on any install that has users, whatever `config.php` says. Previously, if `$config_enable_setup` was missing from `config.php` — a state an install can be left in when setup does not reach its final step — the restore step stayed reachable. Restoring over a live install is now done from the command line.
- A restore no longer takes ITFlow's `uploads/.htaccess` from the archive. The guards are rewritten afterwards regardless of what the backup contained, so restoring a backup taken before those guards existed no longer removes them.
- Tightened the directory guards under `uploads`.
### Bug Fixes
- Deleting a payment now correctly recalculates and sets the invoice status.
- Fixed contact notes, and several broken modal links in contacts, assets and file linking.
- Client Portal: fixed adding saved payment methods and cards following a Stripe API change.
- Fixed sending invoices and quotes over OAUTH2, which was reading the SMTP host instead of the SMTP provider — the host is not filled in when OAUTH2 is selected.
- Mail Parser: correctly work out whether the ITFlow folder belongs under the `INBOX` namespace or the root directory, fixing folder creation on cPanel Dovecot Maildir++ setups.
- Fixed possible duplicate emails caused by a race condition in the mail queue.
- Added a shared lock guard across every cron entry point, scoped per script and per install, replacing the mail queue's non-atomic lock file. Rows left in a sending state by a run that died are now recovered.
- Prevented duplicate Stripe payment bookings and overlapping cron runs.
- Mail bodies are cleared after successful delivery.
- Reworked `getFieldById` to stop escaping its return value, and reworked every caller to escape at the point of use — it was causing double escaping in a lot of places.
- Added missing `maxlength` attributes to forms backed by length-limited columns, so an overlong value no longer throws a 500.
- Fixed undefined variables in the audit log and flash messages for expenses, assets, contacts and several other handlers, which were logging blanks in place of the record name.
- Fixed the spelling of the expense description in audit logging.
- Fixed autofill on invoices, quotes and recurring invoices, where the tax field was not updating and a dash was being placed in front of the product.
- Fixed gaps in ticket history.
- Recurring Expense: fixed editing not keeping the client.
- Fixed client name truncation in the side navigation being applied after escaping.
- Side navigation counts are only shown to users with permission to see them.
- Invoice statistics now only reflect clients the user has permission to see.
- The agent category handler no longer drops the category description.
- Expenses: allowed negative amounts, and the current date is now prefilled.
- Certificates can now be searched by description.
- Fixed cents calculation rounding.
- Fixed guest view credential TOTP display, and removed the legacy OTP code path.
- Gated the SLA option in ticket details, which was gated everywhere else.
- Deleting a ticket template task or a payment provider recorded the wrong name in the audit log and the confirmation message, reading an unrelated record's id in place of the name.
- Bulk-creating tickets from a template against multiple assets only added the template's tasks to the first ticket, and dropped each task's completion estimate.
- Deleting a ticket template now unlinks it from any recurring ticket that referenced it, instead of leaving the schedule pointing at a template that no longer exists.
- Fixed the asset section in the recurring ticket modal when opened outside a client, and project selection when raising a ticket.
- Tickets raised by the nightly recurring schedule were created without a guest URL key, so the "View ticket" link in reply and task approval emails could not be opened. Cron now generates a key like every other path that raises a ticket, and existing tickets missing one are backfilled by the database update.
- Calendar: fixed the last day of a multi-day all-day event not being drawn or published to subscribed feeds. `event_end` holds the last day the event covers, which is what the event modal asks for, but FullCalendar and iCalendar both treat an all-day end as exclusive.
- Fixed `confirm-link` doing nothing inside an ajax modal, where the handler was only bound to links present at page load.
### Developer Updates
- Line endings normalized to LF across the codebase, with `.gitattributes` and `.editorconfig` added. Vendored code under `libs/` is marked so it stays byte identical to upstream.
- Converted to the short echo tag `<?=` throughout.
- `functions.php` is now a loader, with helpers split into topical files under `functions/`. Unused legacy functions removed, including an unused database wrapper layer.
- PHP functions renamed to camelCase throughout, including `nullable_htmlentities` to `escapeHtml`, `sanitizeInput` to `escapeSql`, `logAction` to `logAudit`, and `key32gen` to `generateTotpSecret`.
- Seed data is now shared between the setup wizard and `setup_cli.php` from one file, so a headless install gets the same starter content as a browser install.
- `CONTRIBUTING.md` added and expanded, covering the security rules, style conventions, database column prefix convention and migration pairing.
### Library Updates
- Bump TinyMCE from 8.6.0 to 8.8.2.
- Bump DataTables from 2.3.7 to 3.0.1.
- Bump FullCalendar from 7.0.0 to 7.0.2.
- Bump ImapEngine from 1.25.0 to 1.25.4, along with its dependencies — notably zbateson/mail-mime-parser 3.0.6 to 4.0.3 and guzzlehttp/psr7 2.12.3 to 3.0.0.
## [26.07.1]
### Bug fixes
- Fixed broken M365 and Google Workspaces OAUTH2 in Mail Settings.
- Security Fix.
## [26.07]
### Major Changes

296
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,296 @@
# Contributing to ITFlow
Thanks for your interest in contributing! ITFlow is intentionally simple: plain procedural PHP, MySQL via `mysqli`, and vanilla Bootstrap/AdminLTE. There is no framework, no ORM, no template engine, and no build step. If you can read a PHP file top to bottom, you can read ITFlow.
That simplicity comes with a trade-off: **safety and correctness depend on following conventions at every call site.** This document is the list of those conventions. Read it once, fully, before opening a PR — most review feedback we give is a restatement of something on this page.
---
## Quick start (development)
1. Clone the repo into a webroot served by Apache/PHP 8.x with the `mysqli`.
2. Create a MySQL/MariaDB database and browse to `/setup/` — or import `db.sql` directly.
3. Rename/skip setup as prompted; `config.php` is generated at the root (and is gitignored).
There is no `composer install` or `npm install` step. All third-party libraries are vendored in `/libs/`. This is deliberate — ITFlow is distributed as "unzip and go" — so **never add a runtime Composer/npm dependency**. If a new library is truly needed, discuss it in an issue first; if accepted, it gets vendored into `/libs/`.
---
## Architecture map
| Path | Purpose |
|---|---|
| `agent/` | The main technician-facing app. Most feature work happens here. |
| `admin/` | Settings, configuration, roles, mail, migrations. Admin-only. |
| `client/` | The logged-in client portal (contacts of a client). |
| `guest/` | Unauthenticated flows via URL keys (view/pay invoice, view quote/ticket, view shared credentials/files/documents). |
| `api/v1/` | Key-authenticated JSON CRUD API, one directory per module. |
| `cron/` | Scheduled jobs. `cron.php` is the dispatcher and the only entry in the crontab; everything else in the directory is a job it runs, with `cron/includes/` for the parts only cron uses. See [Cron](#cron). |
| `functions.php` + `functions/` | Shared helper functions, split into topical files (`sanitize.php`, `auth.php`, `logging.php`, …) loaded by `functions.php`. New helpers go in the topical file that matches their concern. |
| `includes/` (root) | **Shared** across portals: session/auth bootstrap, DB, layout partials. |
| `post/` (root) | **Shared** POST handlers (logout, misc). |
| `modals/` (root) | **Shared** modals used by both agent and admin. |
| `js/`, `css/` (root) | Shared front-end assets (portals also have their own). |
| `libs/` | Vendored third-party libraries. Never edit these; update them wholesale. |
| `setup/` | First-run installer. |
| `scripts/` | Helper/utility scripts — `setup_cli.php`, `update_cli.php`, `restore_cli.php`. CLI only; the directory denies web access. |
Rule of thumb: **root-level `includes/`, `post/`, `modals/`, `js/`, `css/` are shared code; everything inside a portal directory is scoped to that portal.**
### `custom/` directories
`agent/`, `admin/`, `client/`, `guest/`, and `cron/` each contain a `custom/` directory. These are hook points for site-specific code that survives updates. `triggerCustomAction($trigger, $entity_id)` fires named triggers (e.g. `ticket_resolve`) into `custom/custom_action_handler.php` if one exists. Core code should **call** `triggerCustomAction()` at meaningful events but never depend on anything inside `custom/`.
---
## Request lifecycle (how a page works)
**Read pages** (`agent/tickets.php`, etc.) start by requiring an `inc_all*.php` from the portal's `includes/`. That chain loads, in order: `config.php``functions.php` (a loader that pulls in the topical helper files under `functions/` — security, sanitize, auth, logging, etc.) → `check_login.php` (auth) → header/nav/layout partials. It also establishes the implicit globals every page relies on: `$mysqli`, `$session_user_id`, `$session_name`, and — on client-scoped pages via `inc_all_client.php``$client_id` (already `intval()`'d).
If your code "can't find" a variable, check which include chain the page uses before adding a query. The variable probably already exists.
**Write actions** go through the portal's `post.php` dispatcher, which:
1. Requires config, functions, and the login check.
2. Defines the constant `FROM_POST_HANDLER`.
3. Loads the handler files in `post/` (excluding `*_model.php`).
Every handler file must start with:
```php
defined('FROM_POST_HANDLER') || die("Direct file access is not allowed");
```
Handlers are a series of independent blocks, one per action:
```php
if (isset($_POST['edit_ticket_priority'])) {
validateCSRFToken($_POST['csrf_token']);
enforceUserPermission('module_support', 2);
// ... fetch, check client access, act, log, notify, redirect
}
```
**Copy the nearest existing block as your starting point** — but understand every line you copy. The next section explains why each one is there.
### The `_model.php` pattern
Files named `agent/post/*_model.php` hold shared field collection/sanitization logic used by both the create and edit blocks of a module (e.g. `asset_model.php` is included by both `add_asset` and `edit_asset`). If create and edit share more than a couple of fields, use this pattern rather than duplicating. Model files carry the same `FROM_POST_HANDLER` guard and are excluded from the dispatcher's auto-load.
**`_model.php` is a reserved suffix.** The exclusion is a filename match, so a *handler* named `*_model.php` is silently never loaded — its form posts, nothing claims the request, and the user gets a blank page with no error anywhere. This is what happened to `admin/post/ai_model.php`, which is why the AI Models handler is now `admin/post/ai_models.php`. Name entity handlers around the suffix (`ai_models.php`, `users.php`, `api_keys.php`).
A POST that reaches the end of `admin/post.php` or `agent/post.php` without a handler claiming it is logged to App Logs as a `Request` warning, which is the fastest way to spot this class of mistake.
---
## Cron
One crontab entry runs everything:
```
* * * * * php /path/to/itflow/cron/cron.php >/dev/null
```
`cron/cron.php` is a dispatcher. It wakes every minute, works out which scripts in `cron/` are due, and requires them into its own process. Adding a job is a new script in `cron/` plus an entry in `includes/cron_jobs.php`. The crontab never changes again.
That registry is the only thing that decides **which** scripts can run, and the schedule in it is only a default: it seeds the job's `cron_jobs` row the first time the dispatcher meets the job, and from then on the row is what runs, because Maintenance > Cron writes to it. The database therefore holds **when and whether**, never **what** — a row naming a script that is not in the registry is ignored, so nothing that reaches the database can point the dispatcher at an arbitrary file. Keep it that way.
Run Now in the admin UI does not execute anything in the web request: these scripts are CLI-only and some take minutes, so the button sets `cron_job_run_now` and the next dispatch picks it up, through the same lock and claim as a scheduled run.
Due-ness is recorded in the `cron_jobs` table rather than matched against the clock, so a job whose minute was missed — machine down, previous run still going — runs at the next opportunity instead of being skipped for the day. A job is claimed *before* it runs, not after: a run that dies half way through is not repeated, which matters because `nightly_tasks.php` generates invoices and charges cards. Each job is also locked individually for the length of its own run (`cron/includes/cron_lock.php`), so a long or hung job holds up only itself — the next minute's dispatch picks up everything else in a second process.
Because the jobs share one PHP process, job code has three rules:
1. **Never `exit()` or `die()`.** It ends the whole cycle and every job after it. Use `cronJobStop($message, $exit_code)` instead: it exits when the script was run directly and unwinds back to the dispatcher when it wasn't, so both paths behave as they always have.
2. **Never declare a function or class another job might declare.** Two jobs each declaring the same helper is a fatal `Cannot redeclare` the moment they share a process. Shared helpers belong in `functions/`.
3. **Be safe to run twice in one day.** The dispatcher's lock stops overlap, but nothing stops a repeat: an admin presses Run Now after the scheduled pass, or a schedule is misconfigured. Work selected by a date match (`... = CURDATE()`) fires again on every run of that day unless something records that it happened — nightly's late fees and overdue reminders guard on the history rows they write. A job whose work cannot be made repeat-safe declares `'interval_safe' => false` in `includes/cron_jobs.php`, which locks it to the daily schedule in Maintenance > Cron and in the dispatcher.
4. **Set what you read.** One global scope and one set of `require_once` includes are shared across the cycle — a job's own `require_once "../config.php"` is a no-op if an earlier job already loaded it, and any variable an earlier job left behind is still there. Do not rely on the state a fresh process would have given you.
A job can also ship switched off with `'enabled' => 0` in the registry. The row is seeded disabled and stays that way until somebody turns it on in Maintenance > Cron. Use it for work an install should opt into rather than inherit silently from an upgrade — `backup` ships this way, because a full backup can be gigabytes a night.
### The master switch
`config_enable_cron` is a second, coarser switch that sits above the per-job ones. It is **not** enforced by the dispatcher — every job checks it in its own header and stops itself with `cronJobStop()`. A new job has to make that check too; one that skips it keeps running on an install that believes cron is off, which is exactly the trap `ticket_email_parser` and `ticket_sla` sat in until 26.08.
Be precise about what it does, because it is easy to oversell. It is **not** a guard on restored data: a full backup dumps the `settings` table, so a restored copy comes back with `config_enable_cron = 1` alongside every enabled `cron_jobs` row, exactly as production had them. What it gives you is one reversible bit — the fastest way to stop an install acting on live data once you have noticed, and the only way to stop everything without editing seven rows.
That last part is the reason it is not redundant with `cron_job_enabled`. Turning the switch off and back on returns you to exactly the configuration you had. Sweeping all seven rows off and back on does not: `backup` ships `'enabled' => 0`, so the sweep quietly turns on nightly backups nobody asked for, along with anything else that was deliberately disabled.
It defaults to `0`. That is a weaker guard than it looks — an install with no crontab entry runs nothing whatever the switch says, so the entry is the real gate — but it does mean adding the crontab line to a half-configured install is not enough on its own to start emailing. Both setup paths name the step on the way out.
## Backups
`functions/backup.php` is the whole engine, and all three entry points go through it: Maintenance > Backup, `cron/backup.php`, and `scripts/restore_cli.php`. Nothing else should dump, zip, or import a database.
Archives are AES-256 encrypted zips. The key is one value per install, generated on first use and appended to `config.php`**never** the database and **never** the file name. That is the point: a backup that leaks cannot be opened with anything the backup itself contains, and a URL or an access log never carries the key. The 32 random characters in the file name are an unguessable path component, nothing more. Note that `unzip`, Windows Explorer and the macOS Archive Utility cannot read AES zips; 7-Zip, WinZip, PeaZip and Keka can.
The web tier never builds an archive in the request. It writes a `Pending` row and `cron/backup.php` does the work, because a dump of a real install outlives `request_terminate_timeout` and `set_time_limit()` does not help. Same reasoning as Run Now.
Two rules for anything touching restore:
1. **Validate before you destroy.** The key is checked and the archive unpacked before a single table is dropped, and the live database is dumped to a rollback file first. If the import fails the rollback goes back in. `mysqli` throws rather than returning false under PHP 8.1's default report mode, so every statement in the import path is wrapped — an uncaught throw there leaves an install with no database at all.
2. **The archive does not get to decide what our guards say.** A restore wipes `uploads/`, and an archive is allowed to contain a `.htaccess`. `backupAssertUploadsGuards()` rewrites ours afterwards unconditionally, and the backup storage directory is preserved through the wipe so a restore cannot destroy every other archive on the box.
Retention lives in `nightly_tasks.php`, never in the backup job, so a failed backup cannot delete the archive it was supposed to replace. It never removes the newest complete backup, and an archive on disk with no row is **adopted** rather than deleted — after a restore the `backups` table is the old one, so everything made since looks unknown.
Setup's restore step closes itself once the `users` table has rows, whatever `config.php` says. It used to default `$config_enable_setup` to `1` when the flag was absent, which fails the wrong way: the flag is only appended at the end of a successful install, so an install abandoned in between left an unauthenticated endpoint that dropped every table, imported an arbitrary archive, and rewrote `uploads/` including the `.htaccess` that stops PHP running there.
Every script in `cron/` still runs standalone (`php cron/mail_queue.php`) and still takes its own lock when it does, so anything can be run by hand for testing.
---
## Security rules (non-negotiable)
ITFlow does not use prepared statements or an ORM; queries are built as strings. That works **only** if every value is neutralized before interpolation. The rules:
### 1. Every value interpolated into SQL is cast or sanitized. No exceptions.
- **Integers** (IDs, flags, counts): `intval($_POST['ticket_id'])`. Interpolate unquoted.
- **Strings**: `escapeSql($_POST['subject'])`. This normalizes encoding to UTF-8, then runs `strip_tags()`, `trim()`, and `mysqli_real_escape_string()`. Because it relies on SQL escaping, the value **must be placed inside quotes in the query** (`'$subject'`). An escaped string interpolated without quotes is still injectable.
- **Values read back from the database** get the same treatment before reuse in another query (you will see `escapeSql($row['ticket_prefix'])` throughout — this is why).
If you write a query and even one variable in it skipped these, that is a SQL injection. This is the single most common review rejection.
**Fetch helpers return raw values — you escape them.** `getFieldById()` and `getTicketStatusName()` hand back exactly what is in the column. Escaping is the call site's job, the same as any other row you read:
```php
$client_name = escapeSql(getFieldById('clients', $client_id, 'client_name')); // into a query
$client_name = escapeHtml(getFieldById('clients', $client_id, 'client_name')); // into a page
$client_id = intval(getFieldById('tickets', $ticket_id, 'ticket_client_id')); // an id
```
Both helpers used to escape internally, via an `$escape_method` argument. It went badly: most call sites wrapped them in `escapeSql()` anyway and got a double-escaped value, so a client named `O'Brien` came back as `O\'Brien` and the backslash reached export filenames, PDF headings, flash messages and — on the user restore path — the database itself. A value that is already safe cannot be made safer, only wrong.
### 2. Every state-changing action validates CSRF.
`validateCSRFToken()` is the first line of every action block. It takes no argument — it reads `csrf_token` from `$_POST`, then `$_GET`, itself, so the same call covers form posts and link-style actions. (The signature still accepts an explicit token for callers that need one, but no call site in the tree passes one; use the bare form.) Forms and action links must include the token; copy how existing modals do it.
### 3. Every action enforces permissions.
`enforceUserPermission('module_x', level)` where level is `1` = read, `2` = write, `3` = full/delete. Current modules: `module_client`, `module_support`, `module_sales`, `module_financial`, `module_credential`, `module_reporting`. Read pages enforce level 1; create/edit enforce 2; destructive actions enforce 3. CSV/PDF exports are reads — gate them with the bare one-argument form, e.g. `enforceUserPermission('module_sales')`.
Two portals are gated differently, which is why their handlers look like they are missing the call:
- **Admin.** `admin/post.php` only loads anything in `admin/post/` when `$session_is_admin` is set, so admin handlers inherit the gate from the dispatcher and do not call `enforceUserPermission()` themselves.
- **Client portal.** `client/post.php` is a single file of action blocks rather than a dispatcher, and gates on the contact's own capabilities with `enforceContactCan('accounting'|'contacts'|'itdoc')`.
Everywhere else — anything under `agent/post/` — the call belongs in the block.
### 4. Client scoping is enforced, not assumed.
A user can be restricted to a subset of clients through `user_client_permissions`. Enforcing that has two halves, and a page usually needs both.
**One record — `enforceClientAccess()`.** After loading a record, call it (optionally with the record's client ID) so technicians restricted to specific clients cannot touch other clients' data by editing an ID in the URL. Look at how `resolve_ticket` does it.
**A list — `clientScopeSql()`.** Any query returning more than one row appends the fragment for that resource's own client column:
```php
$sql = mysqli_query($mysqli, "SELECT expense_id, expense_date, expense_amount, expense_description
FROM expenses
WHERE expense_archived_at IS NULL
" . clientScopeSql('expense_client_id') . "
ORDER BY expense_date DESC");
```
It returns `" AND ..."` or `""`, so it needs a `WHERE` to hang off — add `WHERE 1=1` if the query has no other condition. It is column-aware and takes an alias fine (`clientScopeSql('t.ticket_client_id')`). The API calls the same helper through the `apiClientScopeSql()` wrapper.
Scope on the resource's **own** column, not on a joined `clients.client_id`. Joining `clients` just to scope makes the filter depend on the join: with a `LEFT JOIN`, a row whose client column is `0` produces `NULL`, and `NULL IN (...)` is neither true nor false, so the row silently vanishes. If the query joins `clients` for `client_name`, keep the join for that — but still scope on the owning column.
**Records with no client (`0`) stay visible to restricted users.** `clientScopeSql()` emits `IN (0,...)` deliberately. Client restrictions partition *client* data, and a record belonging to no client is not any client's data to withhold. Do not hand-roll a variant that drops the `0` — the tree had accumulated several before this helper existed, disagreeing with each other, and reconciling them is what surfaced the inconsistency.
### 5. Escape on output.
Anything rendered into HTML goes through `escapeHtml()`. `escapeSql()` on the way in is **not** output escaping — data can enter the DB through other paths (API, email parser, older versions).
In practice the escaping happens **where the row is read, not where it is echoed**. A page or modal fetches its row and assigns each field through `escapeHtml()` once, then echoes the resulting variable raw:
```php
$row = mysqli_fetch_assoc($sql);
$asset_id = intval($row['asset_id']); // ints: intval, not escapeHtml
$asset_name = escapeHtml($row['asset_name']);
...
<strong><?php echo $asset_name; ?></strong>
```
Follow that pattern. Escaping at the echo instead would double-escape a value that is already safe, and mixing the two is how fields get missed. If you introduce a view variable that does not come from a row, escape it at assignment so the rule still holds at the top of the file.
A value that arrives through a fetch helper rather than a row read follows the same rule — `escapeHtml(getFieldById(...))` at the assignment, not at the echo. See rule 1.
Rich-text fields (TinyMCE content) are the exception and have their own handling; follow the existing pattern for the specific field rather than inventing one.
### 6. No shell-outs. No `eval`.
The project has deliberately moved off `shell_exec`/`exec` in favor of native PHP — `dns_get_record()` instead of `dig`, RDAP instead of `whois`, and so on. **Do not add new shell execution or `eval`.** PRs introducing either will be declined.
A handful of legacy call sites survive, all of them wrapping `git` or `which` in the self-update and diagnostics paths: `admin/debug.php`, `admin/update.php`, `admin/post/update.php`, `admin/post/backup.php`, `cron/cron.php`, `functions/app.php`, `scripts/update_cli.php`, `setup/index.php`. They are on the list to be replaced with direct `.git` file reads; treat them as debt, not as precedent.
### 7. Report vulnerabilities privately.
Per [SECURITY.md](SECURITY.md) — never in a public issue.
---
## Conventions
**Only technician-entered time is time worked.** `ticket_replies.ticket_reply_time_worked` is billable labour and feeds ticket totals, the technician and client time reports, project totals, invoicing and the API. A reply the *system* writes — assignment, priority change, merge, close, invoice/quote created, schedule edited, task completed or reopened — is an audit trail, not work, and records `'00:00:00'`. Only a value the technician actually typed goes in that column. Task completion estimates are planning information and stay on the task; they are never converted into time worked. `agent/ticket.php` hides the clock badge on a reply whose time is exactly `00:00:00`, so a zero renders as no time rather than as "0m".
**Database naming.** Every column is prefixed with the singular name of the entity it belongs to: `tickets.ticket_id`, `tickets.ticket_subject`, `clients.client_name`. This makes JOIN results unambiguous, so a `SELECT *` across joins is never *wrong*. New tables must follow it.
**Select the columns you use, not `*`.** Unambiguous is not the same as cheap. `SELECT *` across three joined tables fetches every column of all three, including the `*_notes` and `*_details` TEXT columns, and throws away whatever the page never renders. A search result list that shows five fields was pulling sixty. List the columns instead:
```php
$sql = mysqli_query($mysqli, "SELECT ticket_id, ticket_prefix, ticket_number, ticket_subject, client_name
FROM tickets
LEFT JOIN clients ON ticket_client_id = client_id
WHERE ticket_archived_at IS NULL
" . clientScopeSql('ticket_client_id') . "");
```
Two things follow from that:
- A query whose result only feeds `mysqli_num_rows()` needs no columns at all — write `SELECT 1`. Do not select a primary key "just in case": if the query joins two tables that both carry that column name, an unqualified `SELECT ticket_template_id` is an ambiguous-column error.
- Keep the join even when no column of the joined table survives into the `SELECT`, if the join is doing work — supplying a `WHERE` term, an `ORDER BY`, or the client column you scope on. Dropping a join is a separate decision from trimming the column list.
The trade is real and worth stating: `SELECT *` picks up new columns for free, an explicit list does not. Add a column to a table and every query that needs it must be updated by hand, and the failure mode is a blank field or a PHP 8 undefined-key warning rather than an error. That is the price of not fetching data nobody reads, and the project has decided to pay it on anything that loops or touches a TEXT column.
The exception is `api/v1/*/read.php`. Those endpoints hand the whole row to `read_output.php`, which serialises it straight into the JSON response — there the row *is* the output contract, so `SELECT *` is correct and trimming it would silently drop fields from every consumer.
The prefix is the entity name, which is usually but not always the singular of the table name. Where a table is named for its container rather than its row, the prefix follows the row: `calendar_events``event_*`, `asset_interfaces``interface_*`, `invoice_items` / `quote_items``item_*`, `rack_units``unit_*`, `user_roles``role_*`, `product_stock``stock_*`. Pick the prefix your columns will read best as and use it for every column in the table.
Two standing exceptions: junction tables (`client_tags`, `service_assets`, …) carry the two parent FK names unprefixed, and `settings` / `user_settings` use `config_*` / `user_config_*`.
**Schema changes require two edits in one PR:**
1. `db.sql` — so fresh installs get the new schema.
2. `admin/database_updates/<x.y.z>.php` — a new file named for the version it upgrades **to**, containing only the queries that apply the change. Migrations are sequential and rolling-release; never edit a historical file.
That is the whole job. `LATEST_DATABASE_VERSION` is derived from the highest-numbered filename in `admin/database_updates/`, and the runner (`admin/database_updates.php`) steps `config_current_database_version` after each file succeeds — so there is no constant to bump and no version-bump query to write. Each migration file needs the standard `defined('FROM_DB_UPDATER') || die(...)` guard at the top; copy an existing file's header.
A single update run applies every pending migration in order, stopping at the first failure with the version left at the last file that completed, so a re-run resumes at the one that broke.
**After acting, log and notify.** State changes call `logAudit($type, $action, $description, $client_id, $entity_id)` for the audit trail. User-facing events may also call `appNotify()`. Fire `triggerCustomAction()` where a site might reasonably want a hook. Then call `flashAlert($message, $type)` and `redirect()` (defaults to the referer) rather than setting session keys or `header()` manually.
**Function names (post-rename).** Helpers were renamed for clarity in 2026; the old names **no longer exist** — code calling them fatals. If you're rebasing an old PR or following an old tutorial, translate: `sanitizeInput``escapeSql`, `nullable_htmlentities``escapeHtml`, `logAction``logAudit`, `flash_alert``flashAlert`, `customAction``triggerCustomAction`, `encryptLoginEntry`/`decryptLoginEntry``encryptCredentialEntry`/`decryptCredentialEntry`, `strtoAZaz09``toAlphanumeric`, `fetchUpdates``checkForUpdates`, `sanitize_url``escapeUrl`.
One removed **variable** deserves its own warning: the old `$access_permission_query` global is gone, replaced by `clientScopeSql()` (security rule 4). Unlike a removed function, it does not fatal — an undefined variable interpolates as an empty string, so a rebased query keeps running with **no client scoping at all**. Grep for it before rebasing anything that touches a list query.
**Helpers that fetch data return it raw.** If you add a `getXById()`-style helper, return the column value untouched and let callers escape it (security rule 1). Validating what the helper interpolates into its *own* query — table and column names, the id — is still the helper's job; that is query construction, not output escaping, and the two are not the same thing.
**Bulk vs. single actions.** If you change the behavior of a single action (e.g. resolving a ticket), check whether a `bulk_*` counterpart exists and update it too. They are currently parallel implementations and drift between them is a known bug source.
**UI.** Bootstrap 4 / AdminLTE, modals per-module under `<portal>/modals/<module>/`, DataTables for lists, monospace styling for technical data (IPs, serials, keys) and proportional for human text. Match the page you're standing in.
**Modals post to the portal you are standing in, not the one they live in.** Modal forms use `action="post.php"`, which the browser resolves against the *page* URL, not the modal's own path. A modal under `admin/modals/` that an agent page opens by relative path therefore submits to `agent/post.php` and is handled by `agent/post/`, not `admin/post/`. If you reuse a modal across portals, every portal that can open it needs a handler that accepts the same field set — otherwise fields are silently dropped on one side.
**Style.** Procedural PHP, 4-space indentation, LF line endings, code and comments in English. Match the surrounding code rather than importing a personal style. Don't reformat code you aren't changing — it buries the real diff.
Line endings and indentation are enforced by `.gitattributes` and `.editorconfig` at the repo root, so an editor that respects EditorConfig needs no configuration. `.gitattributes` marks `libs/` as `-text`: vendored code is preserved byte-for-byte as shipped upstream and must never be normalized, or the next wholesale library update turns into an unreviewable diff.
---
## Pull requests
- **Small, focused diffs.** One feature or one fix per PR. Never mix relocation/reformatting with logic changes — split them into separate commits or PRs so each is reviewable on its own.
- Describe **what** and **why**, and note any schema changes prominently.
- CI runs PHP lint and db.sql lint; SonarCloud scans for security issues. Green checks are required but not sufficient — the conventions above are checked by human review.
- Test your change against a real install: fresh setup from `db.sql` **and** an upgrade via `database_updates.php` if you touched schema.
- For anything larger than a bug fix, **open an issue first** and discuss the approach. ITFlow's roadmap favors incremental modernization of the existing PHP codebase; large rewrites, framework introductions, and new runtime dependencies are out of scope.
## Getting help
Open a GitHub issue using the templates, or ask in the community forum linked from the README. When in doubt about a convention, find the closest existing example in the codebase and follow it — consistency beats novelty here.

View File

@@ -13,7 +13,7 @@ We operate a rolling release model. Any bug fixes will be released into latest v
| Version | Supported |
|---------| ------------------ |
| 25.12 | :white_check_mark: |
| 26.08 | :white_check_mark: |
## Reporting a Vulnerability via GitHub Security Advisories

View File

@@ -6,7 +6,8 @@ $order = "ASC";
require_once "includes/inc_all_admin.php";
$sql = mysqli_query($mysqli, "SELECT * FROM ai_models LEFT JOIN ai_providers ON ai_model_ai_provider_id = ai_provider_id ORDER BY $sort $order");
$sql = mysqli_query($mysqli, "SELECT ai_model_id, ai_model_name, ai_model_prompt, ai_model_use_case, ai_provider_id,
ai_provider_name FROM ai_models LEFT JOIN ai_providers ON ai_model_ai_provider_id = ai_provider_id ORDER BY $sort $order");
$num_rows = mysqli_num_rows($sql);
@@ -17,7 +18,7 @@ $num_rows = mysqli_num_rows($sql);
<a href="/admin">Admin</a>
</li>
<li class="breadcrumb-item">
<a href="ai_provider.php">AI Providers</a>
<a href="ai_providers.php">AI Providers</a>
</li>
<li class="breadcrumb-item active">AI Models</li>
</ol>
@@ -35,17 +36,17 @@ $num_rows = mysqli_num_rows($sql);
<thead class="text-dark <?php if ($num_rows == 0) { echo "d-none"; } ?>">
<tr>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=ai_model_name&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=ai_model_name&order=<?= $disp ?>">
Model <?php if ($sort == 'ai_model_name') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=ai_provider_name&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=ai_provider_name&order=<?= $disp ?>">
Provider <?php if ($sort == 'ai_provider_name') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=ai_model_use_case&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=ai_model_use_case&order=<?= $disp ?>">
Use Case<?php if ($sort == 'ai_model_use_case') { echo $order_icon; } ?>
</a>
</th>
@@ -60,23 +61,23 @@ $num_rows = mysqli_num_rows($sql);
while ($row = mysqli_fetch_assoc($sql)) {
$provider_id = intval($row['ai_provider_id']);
$provider_name = nullable_htmlentities($row['ai_provider_name']);
$provider_name = escapeHtml($row['ai_provider_name']);
$model_id = intval($row['ai_model_id']);
$model_name = nullable_htmlentities($row['ai_model_name']);
$use_case = nullable_htmlentities($row['ai_model_use_case']);
$prompt = nl2br(nullable_htmlentities($row['ai_model_prompt']));
$model_name = escapeHtml($row['ai_model_name']);
$use_case = escapeHtml($row['ai_model_use_case']);
$prompt = nl2br(escapeHtml($row['ai_model_prompt']));
?>
<tr>
<td>
<a class="text-dark text-bold ajax-modal" href="#"
data-modal-url="modals/ai/ai_model_edit.php?id=<?= $model_id ?>">
<?php echo $model_name; ?>
<?= $model_name ?>
</a>
</td>
<td><?php echo $provider_name; ?></td>
<td><?php echo $use_case; ?></td>
<td><?php echo $prompt; ?></td>
<td><?= $provider_name ?></td>
<td><?= $use_case ?></td>
<td><?= $prompt ?></td>
<td>
<div class="dropdown dropleft text-center">
<button class="btn btn-secondary btn-sm" type="button" data-toggle="dropdown">
@@ -88,7 +89,7 @@ $num_rows = mysqli_num_rows($sql);
<i class="fas fa-fw fa-edit mr-2"></i>Edit
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger confirm-link" href="post.php?delete_ai_model=<?php echo $model_id; ?>&csrf_token=<?php echo $_SESSION['csrf_token'] ?>">
<a class="dropdown-item text-danger confirm-link" href="post.php?delete_ai_model=<?= $model_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-trash mr-2"></i>Delete
</a>
</div>

View File

@@ -6,7 +6,7 @@ $order = "ASC";
require_once "includes/inc_all_admin.php";
$sql = mysqli_query($mysqli, "SELECT * FROM ai_providers ORDER BY $sort $order");
$sql = mysqli_query($mysqli, "SELECT ai_provider_api_key, ai_provider_api_url, ai_provider_id, ai_provider_name FROM ai_providers ORDER BY $sort $order");
$num_rows = mysqli_num_rows($sql);
@@ -25,17 +25,17 @@ $num_rows = mysqli_num_rows($sql);
<thead class="text-dark <?php if ($num_rows == 0) { echo "d-none"; } ?>">
<tr>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=ai_provider_name&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=ai_provider_name&order=<?= $disp ?>">
Provider <?php if ($sort == 'ai_provider_name') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=ai_provider_api_url&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=ai_provider_api_url&order=<?= $disp ?>">
URL <?php if ($sort == 'ai_provider_api_url') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=ai_provider_api_key&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=ai_provider_api_key&order=<?= $disp ?>">
Key <?php if ($sort == 'ai_provider_api_key') { echo $order_icon; } ?>
</a>
</th>
@@ -50,9 +50,9 @@ $num_rows = mysqli_num_rows($sql);
while ($row = mysqli_fetch_assoc($sql)) {
$provider_id = intval($row['ai_provider_id']);
$provider_name = nullable_htmlentities($row['ai_provider_name']);
$url = nullable_htmlentities($row['ai_provider_api_url']);
$key = nullable_htmlentities($row['ai_provider_api_key']);
$provider_name = escapeHtml($row['ai_provider_name']);
$url = escapeHtml($row['ai_provider_api_url']);
$key = escapeHtml($row['ai_provider_api_key']);
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('ai_model_id') AS ai_model_count FROM ai_models WHERE ai_model_ai_provider_id = $provider_id"));
$ai_model_count = intval($row['ai_model_count']);
@@ -62,13 +62,13 @@ $num_rows = mysqli_num_rows($sql);
<td>
<a class="text-dark text-bold ajax-modal" href="#"
data-modal-url="modals/ai/ai_provider_edit.php?id=<?= $provider_id ?>">
<?php echo $provider_name; ?>
<?= $provider_name ?>
</a>
</td>
<td><?php echo $url; ?></td>
<td><?php echo $key; ?></td>
<td><?= $url ?></td>
<td><?= $key ?></td>
<td class="text-center">
<a class="badge badge-dark badge-pill p-2" href="ai_model.php"><?= $ai_model_count ?></a>
<a class="badge badge-dark badge-pill p-2" href="ai_models.php"><?= $ai_model_count ?></a>
<td>
<div class="dropdown dropleft text-center">
<button class="btn btn-secondary btn-sm" type="button" data-toggle="dropdown">
@@ -80,7 +80,7 @@ $num_rows = mysqli_num_rows($sql);
<i class="fas fa-fw fa-edit mr-2"></i>Edit
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger confirm-link" href="post.php?delete_ai_provider=<?php echo $provider_id; ?>&csrf_token=<?php echo $_SESSION['csrf_token'] ?>">
<a class="dropdown-item text-danger confirm-link" href="post.php?delete_ai_provider=<?= $provider_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-trash mr-2"></i>Delete
</a>
</div>

View File

@@ -8,8 +8,8 @@ require_once "includes/inc_all_admin.php";
$sql = mysqli_query(
$mysqli,
"SELECT SQL_CALC_FOUND_ROWS * FROM api_keys
LEFT JOIN clients on api_keys.api_key_client_id = clients.client_id
"SELECT SQL_CALC_FOUND_ROWS api_key_created_at, api_key_expire, api_key_id, api_key_name, api_key_secret, user_name FROM api_keys
LEFT JOIN users on api_key_user_id = user_id
WHERE (api_key_name LIKE '%$q%')
ORDER BY $sort $order LIMIT $record_from, $record_to"
);
@@ -33,7 +33,7 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<div class="col-md-4">
<div class="input-group mb-3 mb-md-0">
<input type="search" class="form-control" name="q" value="<?php if (isset($q)) { echo stripslashes(nullable_htmlentities($q)); } ?>" placeholder="Search keys">
<input type="search" class="form-control" name="q" value="<?php if (isset($q)) { echo stripslashes(escapeHtml($q)); } ?>" placeholder="Search keys">
<div class="input-group-append">
<button class="btn btn-primary"><i class="fa fa-search"></i></button>
</div>
@@ -64,7 +64,7 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<div class="table-responsive-sm">
<form id="bulkActions" action="post.php" method="post">
<input type="hidden" name="csrf_token" value="<?php echo $_SESSION['csrf_token'] ?>">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<table class="table table-striped table-borderless table-hover">
<thead class="text-dark <?php if ($num_rows[0] == 0) { echo "d-none"; } ?>">
@@ -75,27 +75,27 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
</div>
</td>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=api_key_name&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=api_key_name&order=<?= $disp ?>">
Name <?php if ($sort == 'api_key_name') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=api_key_client_id&order=<?php echo $disp; ?>">
Client <?php if ($sort == 'api_key_client_id') { echo $order_icon; } ?>
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=api_key_user_id&order=<?= $disp ?>">
User <?php if ($sort == 'api_key_user_id') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=api_key_secret&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=api_key_secret&order=<?= $disp ?>">
Secret <?php if ($sort == 'api_key_secret') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=api_key_created_at&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=api_key_created_at&order=<?= $disp ?>">
Created <?php if ($sort == 'api_key_created_at') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=api_key_expire&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=api_key_expire&order=<?= $disp ?>">
Expires <?php if ($sort == 'api_key_expire') { echo $order_icon; } ?>
</a>
</th>
@@ -107,45 +107,44 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
while ($row = mysqli_fetch_assoc($sql)) {
$api_key_id = intval($row['api_key_id']);
$api_key_name = nullable_htmlentities($row['api_key_name']);
$api_key_secret = nullable_htmlentities("************" . substr($row['api_key_secret'], -4));
$api_key_created_at = nullable_htmlentities($row['api_key_created_at']);
$api_key_expire = nullable_htmlentities($row['api_key_expire']);
$api_key_name = escapeHtml($row['api_key_name']);
$api_key_secret = escapeHtml("************" . substr($row['api_key_secret'], -4));
$api_key_created_at = escapeHtml($row['api_key_created_at']);
$api_key_expire = escapeHtml($row['api_key_expire']);
if ($api_key_expire < date("Y-m-d H:i:s")) {
$api_key_expire = $api_key_expire . " (Expired)";
}
if ($row['api_key_client_id'] == 0) {
$api_key_client = "<i>All Clients</i>";
} else {
$api_key_client = nullable_htmlentities($row['client_name']);
}
$api_key_user = !empty($row['user_name']) ? escapeHtml($row['user_name']) : "<i>None</i>";
?>
<tr>
<td class="pr-0">
<div class="form-check">
<input class="form-check-input bulk-select" type="checkbox" name="api_key_ids[]" value="<?php echo $api_key_id ?>">
<input class="form-check-input bulk-select" type="checkbox" name="api_key_ids[]" value="<?= $api_key_id ?>">
</div>
</td>
<td class="text-bold"><?php echo $api_key_name; ?></td>
<td><?php echo $api_key_client; ?></td>
<td><?php echo $api_key_secret; ?></td>
<td><?php echo $api_key_created_at; ?></td>
<td><?php echo $api_key_expire; ?></td>
<td class="text-bold"><?= $api_key_name ?></td>
<td><?= $api_key_user ?></td>
<td><?= $api_key_secret ?></td>
<td><?= $api_key_created_at ?></td>
<td><?= $api_key_expire ?></td>
<td>
<div class="dropdown dropleft text-center">
<button class="btn btn-secondary btn-sm" type="button" data-toggle="dropdown">
<i class="fas fa-ellipsis-h"></i>
</button>
<div class="dropdown-menu">
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/api/api_key_edit.php?id=<?= $api_key_id ?>">
<i class="fas fa-fw fa-edit mr-2"></i>Edit
</a>
<?php if ($api_key_expire > date("Y-m-d H:i:s")) { ?>
<a class="dropdown-item text-danger text-bold confirm-link" href="post.php?revoke_api_key=<?php echo $api_key_id; ?>&csrf_token=<?php echo $_SESSION['csrf_token'] ?>">
<a class="dropdown-item text-danger text-bold confirm-link" href="post.php?revoke_api_key=<?= $api_key_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-times mr-2"></i>Revoke
</a>
<?php } ?>
<?php if ($api_key_expire < date("Y-m-d H:i:s")) { ?>
<a class="dropdown-item text-danger text-bold confirm-link" href="post.php?delete_api_key=<?php echo $api_key_id; ?>&csrf_token=<?php echo $_SESSION['csrf_token'] ?>">
<a class="dropdown-item text-danger text-bold confirm-link" href="post.php?delete_api_key=<?= $api_key_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-times mr-2"></i>Delete
</a>
<?php } ?>

View File

@@ -8,8 +8,8 @@ require_once "includes/inc_all_admin.php";
// Log Type Filter
if (isset($_GET['type']) & !empty($_GET['type'])) {
$log_type_query = "AND (app_log_type = '" . sanitizeInput($_GET['type']) . "')";
$type_filter = nullable_htmlentities($_GET['type']);
$log_type_query = "AND (app_log_type = '" . escapeSql($_GET['type']) . "')";
$type_filter = escapeHtml($_GET['type']);
} else {
// Default - any
$log_type_query = '';
@@ -18,8 +18,8 @@ if (isset($_GET['type']) & !empty($_GET['type'])) {
// Log Category Filter
if (isset($_GET['category']) & !empty($_GET['catergory'])) {
$log_category_query = "AND (app_log_category = '" . sanitizeInput($_GET['category']) . "')";
$category_filter = nullable_htmlentities($_GET['category']);
$log_category_query = "AND (app_log_category = '" . escapeSql($_GET['category']) . "')";
$category_filter = escapeHtml($_GET['category']);
} else {
// Default - any
$log_category_query = '';
@@ -28,7 +28,7 @@ if (isset($_GET['category']) & !empty($_GET['catergory'])) {
$sql = mysqli_query(
$mysqli,
"SELECT SQL_CALC_FOUND_ROWS * FROM app_logs
"SELECT SQL_CALC_FOUND_ROWS app_log_category, app_log_created_at, app_log_details, app_log_id, app_log_type FROM app_logs
WHERE (app_log_type LIKE '%$q%' OR app_log_category LIKE '%$q%' OR app_log_details LIKE '%$q%')
AND DATE(app_log_created_at) BETWEEN '$dtf' AND '$dtt'
$log_type_query
@@ -50,7 +50,7 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<div class="col-sm-4">
<div class="form-group">
<div class="input-group">
<input type="search" class="form-control" name="q" value="<?php if (isset($q)) { echo stripslashes(nullable_htmlentities($q)); } ?>" placeholder="Search app logs">
<input type="search" class="form-control" name="q" value="<?php if (isset($q)) { echo stripslashes(escapeHtml($q)); } ?>" placeholder="Search app logs">
<div class="input-group-append">
<button class="btn btn-secondary" type="button" data-toggle="collapse" data-target="#advancedFilter"><i class="fas fa-filter"></i></button>
<button class="btn btn-primary"><i class="fa fa-search"></i></button>
@@ -67,9 +67,9 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<?php
$sql_types_filter = mysqli_query($mysqli, "SELECT DISTINCT app_log_type FROM app_logs ORDER BY app_log_type ASC");
while ($row = mysqli_fetch_assoc($sql_types_filter)) {
$log_type = nullable_htmlentities($row['app_log_type']);
$log_type = escapeHtml($row['app_log_type']);
?>
<option <?php if ($type_filter == $log_type) { echo "selected"; } ?>><?php echo $log_type; ?></option>
<option <?php if ($type_filter == $log_type) { echo "selected"; } ?>><?= $log_type ?></option>
<?php
}
?>
@@ -86,9 +86,9 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<?php
$sql_categories_filter = mysqli_query($mysqli, "SELECT DISTINCT app_log_category FROM app_logs ORDER BY app_log_category ASC");
while ($row = mysqli_fetch_assoc($sql_categories_filter)) {
$log_category = nullable_htmlentities($row['app_log_category']);
$log_category = escapeHtml($row['app_log_category']);
?>
<option <?php if ($category_filter == $log_category) { echo "selected"; } ?>><?php echo $log_category; ?></option>
<option <?php if ($category_filter == $log_category) { echo "selected"; } ?>><?= $log_category ?></option>
<?php
}
?>
@@ -103,9 +103,9 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<div class="form-group">
<label>Date range</label>
<input type="text" id="dateFilter" class="form-control" autocomplete="off">
<input type="hidden" name="canned_date" id="canned_date" value="<?php echo nullable_htmlentities($_GET['canned_date']) ?? ''; ?>">
<input type="hidden" name="dtf" id="dtf" value="<?php echo nullable_htmlentities($dtf ?? ''); ?>">
<input type="hidden" name="dtt" id="dtt" value="<?php echo nullable_htmlentities($dtt ?? ''); ?>">
<input type="hidden" name="canned_date" id="canned_date" value="<?= escapeHtml($_GET['canned_date']) ?? '' ?>">
<input type="hidden" name="dtf" id="dtf" value="<?= escapeHtml($dtf ?? '') ?>">
<input type="hidden" name="dtt" id="dtt" value="<?= escapeHtml($dtt ?? '') ?>">
</div>
</div>
</div>
@@ -117,22 +117,22 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<thead class="text-dark <?php if ($num_rows[0] == 0) { echo "d-none"; } ?>">
<tr>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=app_log_created_at&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=app_log_created_at&order=<?= $disp ?>">
Timestamp <?php if ($sort == 'app_log_created_at') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=app_log_type&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=app_log_type&order=<?= $disp ?>">
Type <?php if ($sort == 'app_log_type') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=app_log_category&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=app_log_category&order=<?= $disp ?>">
Category <?php if ($sort == 'app_log_category') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=app_log_details&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=app_log_details&order=<?= $disp ?>">
Details <?php if ($sort == 'app_log_details') { echo $order_icon; } ?>
</a>
</th>
@@ -143,18 +143,18 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
while ($row = mysqli_fetch_assoc($sql)) {
$log_id = intval($row['app_log_id']);
$log_type = nullable_htmlentities($row['app_log_type']);
$log_category = nullable_htmlentities($row['app_log_category']);
$log_details = nullable_htmlentities($row['app_log_details']);
$log_created_at = nullable_htmlentities($row['app_log_created_at']);
$log_type = escapeHtml($row['app_log_type']);
$log_category = escapeHtml($row['app_log_category']);
$log_details = escapeHtml($row['app_log_details']);
$log_created_at = escapeHtml($row['app_log_created_at']);
?>
<tr>
<td class="text-monospace"><?php echo $log_created_at; ?></td>
<td><?php echo $log_type; ?></td>
<td><?php echo $log_category; ?></td>
<td><?php echo $log_details; ?></td>
<td class="text-monospace"><?= $log_created_at ?></td>
<td><?= $log_type ?></td>
<td><?= $log_category ?></td>
<td><?= $log_details ?></td>
</tr>
<?php

View File

@@ -28,8 +28,8 @@ if (isset($_GET['client']) & !empty($_GET['client'])) {
// Log Type Filter
if (isset($_GET['type']) & !empty($_GET['type'])) {
$log_type_query = "AND (log_type = '" . sanitizeInput($_GET['type']) . "')";
$type_filter = nullable_htmlentities($_GET['type']);
$log_type_query = "AND (log_type = '" . escapeSql($_GET['type']) . "')";
$type_filter = escapeHtml($_GET['type']);
} else {
// Default - any
$log_type_query = '';
@@ -38,8 +38,8 @@ if (isset($_GET['type']) & !empty($_GET['type'])) {
// Log Action Filter
if (isset($_GET['action']) & !empty($_GET['action'])) {
$log_action_query = "AND (log_action = '" . sanitizeInput($_GET['action']) . "')";
$action_filter = nullable_htmlentities($_GET['action']);
$log_action_query = "AND (log_action = '" . escapeSql($_GET['action']) . "')";
$action_filter = escapeHtml($_GET['action']);
} else {
// Default - any
$log_action_query = '';
@@ -48,7 +48,8 @@ if (isset($_GET['action']) & !empty($_GET['action'])) {
$sql = mysqli_query(
$mysqli,
"SELECT SQL_CALC_FOUND_ROWS * FROM logs
"SELECT SQL_CALC_FOUND_ROWS client_id, client_name, log_action, log_created_at, log_description, log_entity_id, log_id,
log_ip, log_type, log_user_agent, user_id, user_name FROM logs
LEFT JOIN users ON log_user_id = user_id
LEFT JOIN clients ON log_client_id = client_id
WHERE (log_type LIKE '%$q%' OR log_action LIKE '%$q%' OR log_description LIKE '%$q%' OR log_ip LIKE '%$q%' OR log_user_agent LIKE '%$q%' OR user_name LIKE '%$q%' OR client_name LIKE '%$q%')
@@ -73,7 +74,7 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<div class="row">
<div class="col-sm-4">
<div class="input-group mb-3 mb-md-0">
<input type="search" class="form-control" name="q" value="<?php if (isset($q)) { echo stripslashes(nullable_htmlentities($q)); } ?>" placeholder="Search audit logs">
<input type="search" class="form-control" name="q" value="<?php if (isset($q)) { echo stripslashes(escapeHtml($q)); } ?>" placeholder="Search audit logs">
<div class="input-group-append">
<button class="btn btn-secondary" type="button" data-toggle="collapse" data-target="#advancedFilter"><i class="fas fa-filter"></i></button>
<button class="btn btn-primary"><i class="fa fa-search"></i></button>
@@ -87,12 +88,12 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<option value="">- All Clients -</option>
<?php
$sql_clients_filter = mysqli_query($mysqli, "SELECT * FROM clients ORDER BY client_name ASC");
$sql_clients_filter = mysqli_query($mysqli, "SELECT client_id, client_name FROM clients ORDER BY client_name ASC");
while ($row = mysqli_fetch_assoc($sql_clients_filter)) {
$client_id = intval($row['client_id']);
$client_name = nullable_htmlentities($row['client_name']);
$client_name = escapeHtml($row['client_name']);
?>
<option <?php if ($client_filter == $client_id) { echo "selected"; } ?> value="<?php echo $client_id; ?>"><?php echo $client_name; ?></option>
<option <?php if ($client_filter == $client_id) { echo "selected"; } ?> value="<?= $client_id ?>"><?= $client_name ?></option>
<?php
}
?>
@@ -107,12 +108,12 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<option value="">- All Users -</option>
<?php
$sql_users_filter = mysqli_query($mysqli, "SELECT * FROM users ORDER BY user_name ASC");
$sql_users_filter = mysqli_query($mysqli, "SELECT user_id, user_name FROM users ORDER BY user_name ASC");
while ($row = mysqli_fetch_assoc($sql_users_filter)) {
$user_id = intval($row['user_id']);
$user_name = nullable_htmlentities($row['user_name']);
$user_name = escapeHtml($row['user_name']);
?>
<option <?php if ($user_filter == $user_id) { echo "selected"; } ?> value="<?php echo $user_id; ?>"><?php echo $user_name; ?></option>
<option <?php if ($user_filter == $user_id) { echo "selected"; } ?> value="<?= $user_id ?>"><?= $user_name ?></option>
<?php
}
?>
@@ -129,9 +130,9 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<?php
$sql_types_filter = mysqli_query($mysqli, "SELECT DISTINCT log_type FROM logs ORDER BY log_type ASC");
while ($row = mysqli_fetch_assoc($sql_types_filter)) {
$log_type = nullable_htmlentities($row['log_type']);
$log_type = escapeHtml($row['log_type']);
?>
<option <?php if ($type_filter == $log_type) { echo "selected"; } ?>><?php echo $log_type; ?></option>
<option <?php if ($type_filter == $log_type) { echo "selected"; } ?>><?= $log_type ?></option>
<?php
}
?>
@@ -148,9 +149,9 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<?php
$sql_actions_filter = mysqli_query($mysqli, "SELECT DISTINCT log_action FROM logs ORDER BY log_action ASC");
while ($row = mysqli_fetch_assoc($sql_actions_filter)) {
$log_action = nullable_htmlentities($row['log_action']);
$log_action = escapeHtml($row['log_action']);
?>
<option <?php if ($action_filter == $log_action) { echo "selected"; } ?>><?php echo $log_action; ?></option>
<option <?php if ($action_filter == $log_action) { echo "selected"; } ?>><?= $log_action ?></option>
<?php
}
?>
@@ -165,9 +166,9 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<div class="form-group">
<label>Date range</label>
<input type="text" id="dateFilter" class="form-control" autocomplete="off">
<input type="hidden" name="canned_date" id="canned_date" value="<?php echo nullable_htmlentities($_GET['canned_date']) ?? ''; ?>">
<input type="hidden" name="dtf" id="dtf" value="<?php echo nullable_htmlentities($dtf ?? ''); ?>">
<input type="hidden" name="dtt" id="dtt" value="<?php echo nullable_htmlentities($dtt ?? ''); ?>">
<input type="hidden" name="canned_date" id="canned_date" value="<?= escapeHtml($_GET['canned_date']) ?? '' ?>">
<input type="hidden" name="dtf" id="dtf" value="<?= escapeHtml($dtf ?? '') ?>">
<input type="hidden" name="dtt" id="dtt" value="<?= escapeHtml($dtt ?? '') ?>">
</div>
</div>
</div>
@@ -179,44 +180,44 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<thead class="text-dark <?php if ($num_rows[0] == 0) { echo "d-none"; } ?> text-nowrap">
<tr>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=log_created_at&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=log_created_at&order=<?= $disp ?>">
Timestamp <?php if ($sort == 'log_created_at') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=user_name&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=user_name&order=<?= $disp ?>">
User <?php if ($sort == 'user_name') { echo $order_icon; } ?>
</a>
</th>
<?php if (empty($client)) { ?>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=client_name&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=client_name&order=<?= $disp ?>">
Client <?php if ($sort == 'client_name') { echo $order_icon; } ?>
</a>
</th>
<?php } ?>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=log_type&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=log_type&order=<?= $disp ?>">
Type <?php if ($sort == 'log_type') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=log_action&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=log_action&order=<?= $disp ?>">
Action <?php if ($sort == 'log_action') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=log_description&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=log_description&order=<?= $disp ?>">
Description <?php if ($sort == 'log_description') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=log_ip&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=log_ip&order=<?= $disp ?>">
IP Address <?php if ($sort == 'log_ip') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=log_user_agent&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=log_user_agent&order=<?= $disp ?>">
User Agent <?php if ($sort == 'log_user_agent') { echo $order_icon; } ?>
</a>
</th>
@@ -227,22 +228,22 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
while ($row = mysqli_fetch_assoc($sql)) {
$log_id = intval($row['log_id']);
$log_type = nullable_htmlentities($row['log_type']);
$log_action = nullable_htmlentities($row['log_action']);
$log_description = nullable_htmlentities($row['log_description']);
$log_ip = nullable_htmlentities($row['log_ip']);
$log_user_agent = nullable_htmlentities($row['log_user_agent']);
$log_type = escapeHtml($row['log_type']);
$log_action = escapeHtml($row['log_action']);
$log_description = escapeHtml($row['log_description']);
$log_ip = escapeHtml($row['log_ip']);
$log_user_agent = escapeHtml($row['log_user_agent']);
$log_user_os = getOS($log_user_agent);
$log_user_browser = getWebBrowser($log_user_agent);
$log_created_at = nullable_htmlentities($row['log_created_at']);
$log_created_at = escapeHtml($row['log_created_at']);
$user_id = intval($row['user_id']);
$user_name = nullable_htmlentities($row['user_name']);
$user_name = escapeHtml($row['user_name']);
if (empty($user_name)) {
$user_name_display = "-";
} else {
$user_name_display = $user_name;
}
$client_name = nullable_htmlentities($row['client_name']);
$client_name = escapeHtml($row['client_name']);
$client_id = intval($row['client_id']);
if (empty($client_name)) {
$client_name_display = "-";
@@ -254,16 +255,16 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
?>
<tr>
<td class="text-monospace"><?php echo $log_created_at; ?></td>
<td><?php echo $user_name_display; ?></td>
<td class="text-monospace"><?= $log_created_at ?></td>
<td><?= $user_name_display ?></td>
<?php if(empty($client)) { ?>
<td><?php echo $client_name_display; ?></td>
<td><?= $client_name_display ?></td>
<?php } ?>
<td><?php echo $log_type; ?></td>
<td><?php echo $log_action; ?></td>
<td><?php echo $log_description; ?></td>
<td class="text-monospace"><?php echo $log_ip; ?></td>
<td><?php echo "$log_user_os<div class='text-secondary'>$log_user_browser</div>"; ?></td>
<td><?= $log_type ?></td>
<td><?= $log_action ?></td>
<td><?= $log_description ?></td>
<td class="text-monospace"><?= $log_ip ?></td>
<td><?= "$log_user_os<div class='text-secondary'>$log_user_browser</div>" ?></td>
</tr>
<?php

View File

@@ -1,32 +1,288 @@
<?php
require_once "includes/inc_all_admin.php";
$backup_key = backupEncryptionKey();
$backup_dir = backupStorageDir();
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_enable_cron, config_cron_last_dispatch_at, config_backup_retention_days, config_backup_retention_count, config_backup_cron_type FROM settings WHERE company_id = 1"));
$config_enable_cron = intval($row['config_enable_cron']);
$cron_last_dispatch_at = $row['config_cron_last_dispatch_at'];
$config_backup_retention_days = intval($row['config_backup_retention_days']);
$config_backup_retention_count = intval($row['config_backup_retention_count']);
$config_backup_cron_type = $row['config_backup_cron_type'];
// Same heartbeat rule as Maintenance > Cron - archives are built by the dispatcher, so a dead
// crontab means the buttons below queue work that never runs
$cron_is_running = $cron_last_dispatch_at !== null && (time() - strtotime($cron_last_dispatch_at)) < 300;
$backup_job = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT cron_job_enabled, cron_job_daily_at FROM cron_jobs WHERE cron_job_name = 'backup'"));
$backups = mysqli_query($mysqli, "SELECT backup_created_at, backup_error, backup_id, backup_size, backup_source, backup_status,
backup_type FROM backups ORDER BY backup_created_at DESC LIMIT 100");
$pending_count = intval(mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(*) AS c FROM backups WHERE backup_status IN ('Pending','Running')"))['c']);
?>
<?php
// Shown once, immediately after an export, then dropped - it must not survive a refresh
if (!empty($_SESSION['backup_master_key_reveal'])) {
$master_key_reveal = $_SESSION['backup_master_key_reveal'];
unset($_SESSION['backup_master_key_reveal']);
?>
<div class="alert alert-warning">
<h5><i class="fas fa-fw fa-key mr-2"></i>Master encryption key</h5>
<p class="mb-2">Shown once. Refreshing this page will not show it again.</p>
<input type="text" class="form-control text-monospace" value="<?= escapeHtml($master_key_reveal) ?>" readonly onclick="this.select();">
</div>
<?php } ?>
<?php if ($backup_key === '') { ?>
<div class="alert alert-danger">
<h5><i class="fas fa-fw fa-exclamation-triangle mr-2"></i>No backup encryption key</h5>
ITFlow could not write a backup encryption key to <strong>config.php</strong>, so it cannot produce an encrypted backup.
Make config.php writable by the web server user and reload this page, or add a line like
<code>$config_backup_key = '&lt;32 random characters&gt;';</code> to it yourself.
</div>
<?php } ?>
<?php if (!$cron_is_running) { ?>
<div class="alert alert-danger">
<h5><i class="fas fa-fw fa-exclamation-triangle mr-2"></i>Cron is not running</h5>
Backups are built by the cron dispatcher, not by your browser. Until cron is running, anything you
start here will sit in the queue. See <a href="cron.php">Maintenance &gt; Cron</a>.
</div>
<?php } elseif ($config_enable_cron == 0) { ?>
<div class="alert alert-warning">
<i class="fas fa-fw fa-exclamation-circle mr-2"></i>Cron is switched off in
<a href="cron.php">Maintenance &gt; Cron</a>.
</div>
<?php } ?>
<div class="card card-dark mb-3">
<div class="card-header py-3">
<h3 class="card-title"><i class="fas fa-fw fa-database mr-2"></i>Download Database</h3>
<h3 class="card-title"><i class="fas fa-fw fa-cloud-upload-alt mr-2"></i>Create a Backup</h3>
</div>
<div class="card-body" style="text-align: center;">
<div class="alert alert-secondary">If you are unable to back up the entire VM, you'll need to back up the files & database individually. There is no built-in restore. See the <a href="https://docs.itflow.org/backups" target="_blank">docs here</a>.</div>
<a class="btn btn-primary btn-lg p-3" href="post.php?download_backup&csrf_token=<?php echo $_SESSION['csrf_token'] ?>"><i class="fas fa-fw fa-4x fa-download"></i><br><br>Download Backup</a>
<div class="card-body">
<?php if ($pending_count > 0) { ?>
<div class="alert alert-info">
<i class="fas fa-fw fa-spinner mr-2"></i><strong><?= $pending_count ?></strong> backup<?= $pending_count == 1 ? ' is' : 's are' ?>
queued or building. You will get a notification when ready - this page does not refresh itself.
</div>
<?php } ?>
<div class="row">
<div class="col-md-4 mb-3">
<div class="border rounded p-3 h-100 text-center">
<i class="fas fa-fw fa-3x fa-box-open text-dark mb-3"></i>
<h5>Full Backup</h5>
<p class="text-muted small">The database and everything in the uploads folder. This is the one to keep.</p>
<a class="btn btn-primary <?= $backup_key === '' ? 'disabled' : '' ?>" href="post.php?queue_backup=full&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-play mr-2"></i>Start
</a>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="border rounded p-3 h-100 text-center">
<i class="fas fa-fw fa-3x fa-database text-dark mb-3"></i>
<h5>Database Only</h5>
<p class="text-muted small">Just the SQL dump. Much smaller and much quicker, but no attachments or documents.</p>
<a class="btn btn-primary <?= $backup_key === '' ? 'disabled' : '' ?>" href="post.php?queue_backup=database&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-play mr-2"></i>Start
</a>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="border rounded p-3 h-100 text-center">
<i class="fas fa-fw fa-3x fa-key text-dark mb-3"></i>
<h5>Master Key</h5>
<p class="text-muted small">The credential vault key. Only needed if every user password is lost - a normal restore recovers the vault on its own.</p>
<button type="button" class="btn btn-secondary" data-toggle="modal" data-target="#masterKeyModal">
<i class="fas fa-fw fa-key mr-2"></i>Export
</button>
</div>
</div>
</div>
</div>
</div>
<div class="card card-dark mb-3">
<div class="card-header py-3">
<h3 class="card-title"><i class="fas fa-fw fa-archive mr-2"></i>Backups</h3>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-striped table-borderless mb-0">
<thead class="text-dark">
<tr>
<th>Type</th>
<th>Created</th>
<th>Size</th>
<th>Source</th>
<th>Status</th>
<th class="text-right">Action</th>
</tr>
</thead>
<tbody>
<?php if (mysqli_num_rows($backups) === 0) { ?>
<tr><td colspan="6" class="text-center text-muted py-4">No backups yet.</td></tr>
<?php } ?>
<?php while ($backup = mysqli_fetch_assoc($backups)) {
$backup_id = intval($backup['backup_id']);
$status = $backup['backup_status'];
$badge = 'secondary';
if ($status === 'Complete') { $badge = 'success'; }
if ($status === 'Failed') { $badge = 'danger'; }
if ($status === 'Missing') { $badge = 'warning'; }
if ($status === 'Running' || $status === 'Pending') { $badge = 'info'; }
?>
<tr>
<td><?= escapeHtml(backupTypeLabel($backup['backup_type'])) ?></td>
<td><?= escapeHtml($backup['backup_created_at']) ?></td>
<td><?= $backup['backup_size'] > 0 ? escapeHtml(backupFormatBytes($backup['backup_size'])) : '-' ?></td>
<td><?= escapeHtml($backup['backup_source']) ?></td>
<td>
<span class="badge badge-<?= $badge ?>"><?= escapeHtml($status) ?></span>
<?php if (!empty($backup['backup_error'])) { ?>
<br><small class="text-danger"><?= escapeHtml($backup['backup_error']) ?></small>
<?php } ?>
</td>
<td class="text-right">
<?php if ($status === 'Complete') { ?>
<a class="btn btn-sm btn-primary" href="backup_download.php?backup_id=<?= $backup_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-download"></i>
</a>
<?php } ?>
<a class="btn btn-sm btn-danger confirm-link" href="post.php?delete_backup=<?= $backup_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-trash"></i>
</a>
</td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
</div>
</div>
<div class="card card-dark mb-3">
<div class="card-header py-3">
<h3 class="card-title"><i class="fas fa-fw fa-lock mr-2"></i>Encryption Key</h3>
</div>
<div class="card-body">
<div class="alert alert-warning mb-3">
<i class="fas fa-fw fa-exclamation-triangle mr-2"></i>
<strong>Write this down and keep it somewhere other than this server.</strong>
Every backup is encrypted with it, and without it a backup cannot be restored - not by you,
not by anyone. It is stored in config.php and never in the database, which is what stops a
stolen backup from carrying its own key.
</div>
<?php if ($backup_key !== '') { ?>
<div class="input-group col-md-6 px-0">
<input type="text" class="form-control text-monospace" value="<?= escapeHtml($backup_key) ?>" readonly onclick="this.select();">
<div class="input-group-append">
<button class="btn btn-secondary" type="button" onclick="navigator.clipboard.writeText('<?= escapeHtml($backup_key) ?>');">
<i class="fas fa-fw fa-copy"></i>
</button>
</div>
</div>
<?php } ?>
<p class="text-muted small mt-3 mb-0">
Archives are AES-256 encrypted zips. <strong>7-Zip, WinZip, PeaZip and Keka</strong> can open them with this key.
The <code>unzip</code> command, Windows Explorer and the macOS Archive Utility cannot - they do not support AES.
</p>
</div>
</div>
<div class="card card-dark mb-3">
<div class="card-header py-3">
<h3 class="card-title"><i class="fas fa-fw fa-clock mr-2"></i>Scheduled Backups &amp; Retention</h3>
</div>
<div class="card-body">
<form action="post.php" method="POST" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="form-row">
<div class="form-group col-md-4">
<label>Scheduled backup type</label>
<select class="form-control" name="config_backup_cron_type">
<option <?= $config_backup_cron_type === 'full' ? 'selected' : '' ?> value="full">Full Backup</option>
<option <?= $config_backup_cron_type === 'database' ? 'selected' : '' ?> value="database">Database Only</option>
</select>
</div>
<div class="form-group col-md-4">
<label>Keep backups for (days)</label>
<input type="number" class="form-control" name="config_backup_retention_days" min="0" value="<?= intval($config_backup_retention_days) ?>">
<small class="text-muted">0 disables age-based deletion.</small>
</div>
<div class="form-group col-md-4">
<label>Keep at most (per type)</label>
<input type="number" class="form-control" name="config_backup_retention_count" min="1" value="<?= intval($config_backup_retention_count) ?>">
<small class="text-muted">Counted separately for each type. The newest of each is never deleted.</small>
</div>
</div>
<button type="submit" name="edit_backup_settings" class="btn btn-primary"><i class="fas fa-fw fa-check mr-2"></i>Save</button>
</form>
<hr>
<p class="mb-0">
<?php if (!empty($backup_job) && intval($backup_job['cron_job_enabled']) === 1) { ?>
<i class="fas fa-fw fa-check text-success mr-2"></i>Scheduled backups run daily at
<strong><?= escapeHtml(substr((string)$backup_job['cron_job_daily_at'], 0, 5)) ?></strong>.
<?php } else { ?>
<i class="fas fa-fw fa-times text-danger mr-2"></i>Scheduled backups are switched off.
<?php } ?>
Turn them on or change the time in <a href="cron.php">Maintenance &gt; Cron</a>.
</p>
<p class="text-muted small mt-2 mb-0">
Old backups are removed by the nightly job, never by the backup itself, so a failed nightly
cannot delete an archive that was never replaced.
</p>
</div>
</div>
<div class="card card-dark">
<div class="card-header py-3">
<h3 class="card-title"><i class="fas fa-fw fa-key mr-2"></i>Backup Master Encryption Key</h3>
<h3 class="card-title"><i class="fas fa-fw fa-undo mr-2"></i>Restoring</h3>
</div>
<div class="card-body">
<div class="card-body">
<form action="post.php" method="POST">
<input type="hidden" name="csrf_token" value="<?php echo $_SESSION['csrf_token'] ?>">
<div class="d-flex justify-content-center">
<div class="input-group col-sm-4">
<input type="password" class="form-control" placeholder="Enter your account password" name="password" autocomplete="new-password" required>
<div class="input-group-append">
<button class="btn btn-primary" type="submit" name="backup_master_key"><i class="fas fa-key"></i></button>
</div>
<p>Restoring replaces the database and the uploads folder with what is in the archive. It cannot be done from here, on purpose - a running install is the wrong place to be dropping its own tables from a browser.</p>
<p class="mb-2"><strong>From the command line</strong> - the only option that works for large backups:</p>
<pre class="bg-dark text-white p-2"><?= escapeHtml("php " . dirname(__DIR__) . "/scripts/restore_cli.php --file=/path/to/backup.zip") ?></pre>
<p class="mb-0"><strong>From a browser</strong>, on a fresh install only, the setup wizard has a restore step at <code>/setup</code>. Once an install has users, that step closes itself.</p>
</div>
</div>
<div class="modal" id="masterKeyModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-fw fa-key mr-2"></i>Export Master Key</h5>
<button type="button" class="close" data-dismiss="modal"><span>&times;</span></button>
</div>
<form action="post.php" method="POST" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="modal-body">
<div class="alert alert-warning">
This key decrypts every credential in this install. It is shown on screen and is not written anywhere.
</div>
<div class="form-group">
<label>Confirm your account password</label>
<input type="password" class="form-control" name="password" autocomplete="new-password" required>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="backup_master_key" class="btn btn-primary"><i class="fas fa-fw fa-key mr-2"></i>Show Master Key</button>
</div>
</form>
</div>
@@ -35,4 +291,3 @@ require_once "includes/inc_all_admin.php";
<?php
require_once "../includes/footer.php";

61
admin/backup_download.php Normal file
View File

@@ -0,0 +1,61 @@
<?php
/*
* ITFlow - GET /admin/backup_download.php
* Streams a backup archive to an administrator
*
* Deliberately NOT agent/file.php: that gates on module_client and resolves paths under
* uploads/clients/<id>/, which would hand a full database dump to any agent with client
* read access. A backup is an admin artifact and gets an admin-only path of its own.
*/
require_once "../config.php";
require_once "../functions.php";
require_once "../includes/check_login.php";
enforceAdminPermission();
validateCSRFToken();
if (!isset($_GET['backup_id'])) {
http_response_code(400);
exit("Backup ID required");
}
$backup_id = intval($_GET['backup_id']);
$sql = mysqli_query($mysqli, "SELECT backup_file_name FROM backups WHERE backup_id = $backup_id AND backup_status = 'Complete' LIMIT 1");
if (mysqli_num_rows($sql) !== 1) {
http_response_code(404);
exit("Backup not found");
}
$row = mysqli_fetch_assoc($sql);
$file_path = backupResolvePath($row['backup_file_name']);
if ($file_path === false || !is_file($file_path)) {
mysqli_query($mysqli, "UPDATE backups SET backup_status = 'Missing' WHERE backup_id = $backup_id");
http_response_code(404);
exit("Backup file is no longer on disk");
}
$file_name = basename($file_path);
logAudit("Backup", "Download", ($session_name ?? 'Unknown User') . " downloaded backup " . escapeSql($file_name));
mysqli_query($mysqli, "UPDATE backups SET backup_downloaded_at = NOW() WHERE backup_id = $backup_id");
header("Content-Type: application/zip");
header("Content-Disposition: attachment; filename=\"$file_name\"");
header("Content-Length: " . filesize($file_path));
header("X-Content-Type-Options: nosniff");
header("Cache-Control: private, no-store");
header("Pragma: no-cache");
// Clear output buffers so a multi-gigabyte archive streams instead of loading into memory
while (ob_get_level()) {
ob_end_clean();
}
readfile($file_path);
exit;

219
admin/categories.php Normal file
View File

@@ -0,0 +1,219 @@
<?php
// Default Column Sortby Filter
$sort = "category_name";
$order = "ASC";
require_once "includes/inc_all_admin.php";
if (isset($_GET['category'])) {
$category = escapeSql($_GET['category']);
} else {
$category = "Expense";
}
$sql = mysqli_query(
$mysqli,
"SELECT SQL_CALC_FOUND_ROWS category_color, category_description, category_id, category_name FROM categories
WHERE category_name LIKE '%$q%'
AND category_type = '$category'
AND category_$archive_query
ORDER BY $sort $order LIMIT $record_from, $record_to"
);
$num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
// Category types shown in the left nav
$category_types = [
'Expense' => ['label' => 'Expense', 'icon' => 'fa-shopping-cart'],
'Income' => ['label' => 'Income', 'icon' => 'fa-hand-holding-usd'],
'Referral' => ['label' => 'Referral', 'icon' => 'fa-share-alt'],
'Ticket' => ['label' => 'Ticket', 'icon' => 'fa-life-ring'],
'network_interface' => ['label' => 'Network Interface', 'icon' => 'fa-ethernet'],
'asset_status' => ['label' => 'Asset Status', 'icon' => 'fa-heartbeat'],
'software_type' => ['label' => 'Software Type', 'icon' => 'fa-cube'],
'rack_type' => ['label' => 'Rack Type', 'icon' => 'fa-server'],
'contact_note_type' => ['label' => 'Contact Note Type', 'icon' => 'fa-address-book'],
'asset_note_type' => ['label' => 'Asset Note Type', 'icon' => 'fa-desktop'],
];
// Label for the selected type, falling back for anything not in the map
$category_label = $category_types[$category]['label'] ?? ucwords(str_replace('_', ' ', $category));
// Row count per type for the nav badges, respecting the archived view
$category_type_counts = [];
$sql_category_type_counts = mysqli_query(
$mysqli,
"SELECT category_type, COUNT(category_id) AS category_type_count FROM categories
WHERE category_$archive_query
GROUP BY category_type"
);
while ($row = mysqli_fetch_assoc($sql_category_type_counts)) {
$category_type_counts[$row['category_type']] = intval($row['category_type_count']);
}
// Archived nav item toggles the view while holding the selected type/search
$archive_toggle_query = $_GET;
unset($archive_toggle_query['page']);
$archive_toggle_query['category'] = $category;
if ($archived) {
unset($archive_toggle_query['archived']);
} else {
$archive_toggle_query['archived'] = 1;
}
$archive_toggle_url = '?' . http_build_query($archive_toggle_query);
?>
<div class="card card-dark">
<div class="card-header py-2">
<h3 class="card-title mt-2"><i class="fa fa-fw fa-list-ul mr-2"></i>
<?= escapeHtml($category_label) ?> Categories
</h3>
<?php
if (!isset($_GET['archived'])) {
?>
<div class="card-tools">
<button type="button" class="btn btn-primary ajax-modal" data-modal-url="modals/category/category_add.php?category=<?= escapeHtml($category) ?>"><i
class="fas fa-plus mr-2"></i>New <?= escapeHtml($category_label) ?> Category</button>
</div>
<?php
}
?>
</div>
<div class="card-body">
<div class="row">
<!-- Category types -->
<div class="col-md-3 border-right mb-3">
<ul class="nav nav-pills flex-column bg-light">
<?php foreach ($category_types as $category_type => $category_type_details) { ?>
<li class="nav-item">
<a class="nav-link<?php if ($category == $category_type) {
echo ' active';
} ?>" href="?category=<?= urlencode($category_type) ?>">
<i class="fa fa-fw <?= $category_type_details['icon'] ?> mr-2"></i><?= escapeHtml($category_type_details['label']) ?>
<span class="badge badge-pill badge-dark float-right mt-1"><?= $category_type_counts[$category_type] ?? 0 ?></span>
</a>
</li>
<?php } ?>
</ul>
</div>
<!-- Categories -->
<div class="col-md-9">
<form autocomplete="off">
<input type="hidden" name="category" value="<?= escapeHtml($category) ?>">
<?php if ($archived) { ?>
<input type="hidden" name="archived" value="1">
<?php } ?>
<div class="row">
<div class="col-sm-6 mb-2">
<div class="input-group">
<input type="search" class="form-control" name="q"
value="<?php if (isset($q)) {
echo stripslashes(escapeHtml($q));
} ?>"
placeholder="Search <?= escapeHtml($category_label) ?> Categories ">
<div class="input-group-append">
<button class="btn btn-primary"><i class="fa fa-search"></i></button>
</div>
</div>
</div>
<div class="col-sm-6 mb-2">
<a href="<?= $archive_toggle_url ?>"
class="btn float-right <?php if ($archived) {
echo 'btn-primary';
} else {
echo 'btn-default';
} ?>"><i
class="fas fa-fw fa-archive mr-2"></i>Archived</a>
</div>
</div>
</form>
<hr>
<div class="table-responsive-sm">
<table class="table table-striped table-borderless table-hover">
<thead class="text-dark <?php if ($num_rows[0] == 0) { echo "d-none"; } ?>">
<tr>
<th>
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=category_name&order=<?= $disp ?>">
Name <?php if ($sort == 'category_name') { echo $order_icon; } ?>
</a>
</th>
<th>Color</th>
<th class="text-center">Action</th>
</tr>
</thead>
<tbody>
<?php
while ($row = mysqli_fetch_assoc($sql)) {
$category_id = intval($row['category_id']);
$category_name = escapeHtml($row['category_name']);
$category_description = escapeHtml($row['category_description']);
$category_color = escapeHtml($row['category_color']);
?>
<tr>
<td>
<a class="text-dark ajax-modal" href="#"
data-modal-url="modals/category/category_edit.php?id=<?= $category_id ?>">
<?= $category_name ?>
<div><small class="text-secondary"><?= $category_description ?></small></div>
</a>
</td>
<td><i class="fa fa-3x fa-circle" style="color:<?= $category_color ?>;"></i></td>
<td>
<div class="dropdown dropleft text-center">
<button class="btn btn-secondary btn-sm" type="button" data-toggle="dropdown">
<i class="fas fa-ellipsis-h"></i>
</button>
<div class="dropdown-menu">
<?php
if ($archived) {
?>
<a class="dropdown-item text-info confirm-link"
href="post.php?restore_category=<?= $category_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-redo mr-2"></i>Restore
</a>
<a class="dropdown-item text-danger confirm-link"
href="post.php?delete_category=<?= $category_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-trash mr-2"></i>Delete
</a>
<?php
} else {
?>
<a class="dropdown-item ajax-modal" href="#"
data-modal-url="modals/category/category_edit.php?id=<?= $category_id ?>">
<i class="fas fa-fw fa-edit mr-2"></i>Edit
</a>
<a class="dropdown-item text-danger confirm-link"
href="post.php?archive_category=<?= $category_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-archive mr-2"></i>Archive
</a>
<?php
}
?>
</div>
</div>
</td>
</tr>
<?php
}
?>
</tbody>
</table>
</div>
<?php require_once "../includes/filter_footer.php"; ?>
</div>
</div>
</div>
</div>
<?php
require_once "../includes/footer.php";

View File

@@ -1,211 +0,0 @@
<?php
// Default Column Sortby Filter
$sort = "category_name";
$order = "ASC";
require_once "includes/inc_all_admin.php";
if (isset($_GET['category'])) {
$category = sanitizeInput($_GET['category']);
} else {
$category = "Expense";
}
$sql = mysqli_query(
$mysqli,
"SELECT SQL_CALC_FOUND_ROWS * FROM categories
WHERE category_name LIKE '%$q%'
AND category_type = '$category'
AND category_$archive_query
ORDER BY $sort $order LIMIT $record_from, $record_to"
);
$num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
?>
<div class="card card-dark">
<div class="card-header py-2">
<h3 class="card-title mt-2"><i class="fa fa-fw fa-list-ul mr-2"></i>
<?= nullable_htmlentities(ucwords(str_replace('_', ' ', $category))); ?> Categories
</h3>
<?php
if (!isset($_GET['archived'])) {
?>
<div class="card-tools">
<button type="button" class="btn btn-primary ajax-modal" data-modal-url="modals/category/category_add.php?category=<?= nullable_htmlentities($category) ?>"><i
class="fas fa-plus mr-2"></i>New <?= nullable_htmlentities(ucwords(str_replace('_', ' ', $category))); ?> Category</button>
</div>
<?php
}
?>
</div>
<div class="card-body">
<form autocomplete="off">
<input type="hidden" name="category" value="<?php echo nullable_htmlentities($category); ?>">
<div class="row">
<div class="col-sm-4 mb-2">
<div class="input-group">
<input type="search" class="form-control" name="q"
value="<?php if (isset($q)) {
echo stripslashes(nullable_htmlentities($q));
} ?>"
placeholder="Search <?= nullable_htmlentities(ucwords(str_replace('_', ' ', $category))); ?> Categories ">
<div class="input-group-append">
<button class="btn btn-primary"><i class="fa fa-search"></i></button>
</div>
</div>
</div>
<div class="col-sm-8">
<div class="btn-group float-right">
<a href="?category=Expense"
class="btn <?php if ($category == 'Expense') {
echo 'btn-primary';
} else {
echo 'btn-default';
} ?>">Expense</a>
<a href="?category=Income"
class="btn <?php if ($category == 'Income') {
echo 'btn-primary';
} else {
echo 'btn-default';
} ?>">Income</a>
<a href="?category=Referral"
class="btn <?php if ($category == 'Referral') {
echo 'btn-primary';
} else {
echo 'btn-default';
} ?>">Referral</a>
<a href="?category=Ticket"
class="btn <?php if ($category == 'Ticket') {
echo 'btn-primary';
} else {
echo 'btn-default';
} ?>">Ticket</a>
<a href="?category=network_interface"
class="btn <?php if ($category == 'network_interface') {
echo 'btn-primary';
} else {
echo 'btn-default';
} ?>">Network Interface</a>
<a href="?category=asset_status"
class="btn <?php if ($category == 'asset_status') {
echo 'btn-primary';
} else {
echo 'btn-default';
} ?>">Asset Status</a>
<a href="?category=software_type"
class="btn <?php if ($category == 'software_type') {
echo 'btn-primary';
} else {
echo 'btn-default';
} ?>">Software Type</a>
<a href="?category=rack_type"
class="btn <?php if ($category == 'rack_type') {
echo 'btn-primary';
} else {
echo 'btn-default';
} ?>">Rack Type</a>
<a href="?category=contact_note_type"
class="btn <?php if ($category == 'contact_note_type') {
echo 'btn-primary';
} else {
echo 'btn-default';
} ?>">Contact Note Type</a>
<a href="?<?php echo $url_query_strings_sort ?>&archived=1"
class="btn <?php if (isset($_GET['archived'])) {
echo 'btn-primary';
} else {
echo 'btn-default';
} ?>"><i
class="fas fa-fw fa-archive mr-2"></i>Archived</a>
</div>
</div>
</div>
</form>
<hr>
<div class="table-responsive-sm">
<table class="table table-striped table-borderless table-hover">
<thead class="text-dark <?php if ($num_rows[0] == 0) { echo "d-none"; } ?>">
<tr>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=category_name&order=<?php echo $disp; ?>">
Name <?php if ($sort == 'category_name') { echo $order_icon; } ?>
</a>
</th>
<th>Color</th>
<th class="text-center">Action</th>
</tr>
</thead>
<tbody>
<?php
while ($row = mysqli_fetch_assoc($sql)) {
$category_id = intval($row['category_id']);
$category_name = nullable_htmlentities($row['category_name']);
$category_description = nullable_htmlentities($row['category_description']);
$category_color = nullable_htmlentities($row['category_color']);
?>
<tr>
<td>
<a class="text-dark ajax-modal" href="#"
data-modal-url="modals/category/category_edit.php?id=<?= $category_id ?>">
<?php echo $category_name; ?>
<div><small class="text-secondary"><?= $category_description ?></small></div>
</a>
</td>
<td><i class="fa fa-3x fa-circle" style="color:<?php echo $category_color; ?>;"></i></td>
<td>
<div class="dropdown dropleft text-center">
<button class="btn btn-secondary btn-sm" type="button" data-toggle="dropdown">
<i class="fas fa-ellipsis-h"></i>
</button>
<div class="dropdown-menu">
<?php
if ($archived) {
?>
<a class="dropdown-item text-info confirm-link"
href="post.php?restore_category=<?php echo $category_id; ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-redo mr-2"></i>Restore
</a>
<a class="dropdown-item text-danger confirm-link"
href="post.php?delete_category=<?php echo $category_id; ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-trash mr-2"></i>Delete
</a>
<?php
} else {
?>
<a class="dropdown-item ajax-modal" href="#"
data-modal-url="modals/category/category_edit.php?id=<?= $category_id ?>">
<i class="fas fa-fw fa-edit mr-2"></i>Edit
</a>
<a class="dropdown-item text-danger confirm-link"
href="post.php?archive_category=<?php echo $category_id; ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-archive mr-2"></i>Archive
</a>
<?php
}
?>
</div>
</div>
</td>
</tr>
<?php
}
?>
</tbody>
</table>
</div>
<?php require_once "../includes/filter_footer.php";
?>
</div>
</div>
<?php
require_once "../includes/footer.php";

View File

@@ -31,7 +31,7 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<form autocomplete="off">
<div class="input-group">
<input type="search" class="form-control" name="q" value="<?php if (isset($q)) { echo stripslashes(nullable_htmlentities($q)); } ?>" placeholder="Search templates">
<input type="search" class="form-control" name="q" value="<?php if (isset($q)) { echo stripslashes(escapeHtml($q)); } ?>" placeholder="Search templates">
<div class="input-group-append">
<button class="btn btn-secondary"><i class="fa fa-search"></i></button>
</div>
@@ -61,39 +61,39 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<?php
while ($row = mysqli_fetch_assoc($sql)) {
$id = intval($row['contract_template_id']);
$name = nullable_htmlentities($row['contract_template_name']);
$type = nullable_htmlentities($row['contract_template_type']);
$freq = nullable_htmlentities($row['contract_template_update_frequency']);
$sla_low_resp = nullable_htmlentities($row['sla_low_response_time']);
$sla_med_resp = nullable_htmlentities($row['sla_medium_response_time']);
$sla_high_resp = nullable_htmlentities($row['sla_high_response_time']);
$sla_low_res = nullable_htmlentities($row['sla_low_resolution_time']);
$sla_med_res = nullable_htmlentities($row['sla_medium_resolution_time']);
$sla_high_res = nullable_htmlentities($row['sla_high_resolution_time']);
$hourly_rate = nullable_htmlentities($row['contract_template_hourly_rate']);
$after_hours = nullable_htmlentities($row['contract_template_after_hours_hourly_rate']);
$support_hours = nullable_htmlentities($row['contract_template_support_hours']);
$net_terms = nullable_htmlentities($row['contract_template_net_terms']);
$created = nullable_htmlentities($row['contract_template_created_at']);
$updated = nullable_htmlentities($row['contract_template_updated_at']);
$name = escapeHtml($row['contract_template_name']);
$type = escapeHtml($row['contract_template_type']);
$freq = escapeHtml($row['contract_template_renewal_frequency']);
$sla_low_resp = escapeHtml($row['contract_template_sla_low_response_time']);
$sla_med_resp = escapeHtml($row['contract_template_sla_medium_response_time']);
$sla_high_resp = escapeHtml($row['contract_template_sla_high_response_time']);
$sla_low_res = escapeHtml($row['contract_template_sla_low_resolution_time']);
$sla_med_res = escapeHtml($row['contract_template_sla_medium_resolution_time']);
$sla_high_res = escapeHtml($row['contract_template_sla_high_resolution_time']);
$hourly_rate = escapeHtml($row['contract_template_rate_standard']);
$after_hours = escapeHtml($row['contract_template_rate_after_hours']);
$support_hours = escapeHtml($row['contract_template_support_hours']);
$net_terms = escapeHtml($row['contract_template_net_terms']);
$created = escapeHtml($row['contract_template_created_at']);
$updated = escapeHtml($row['contract_template_updated_at']);
?>
<tr>
<td>
<a class="text-bold" href="contract_template_details.php?contract_template_id=<?php echo $id; ?>">
<i class="fas fa-fw fa-file-alt text-dark"></i> <?php echo $name; ?>
<a class="text-bold" href="contract_template_details.php?contract_template_id=<?= $id ?>">
<i class="fas fa-fw fa-file-alt text-dark"></i> <?= $name ?>
</a>
<div class="mt-1 text-secondary"><?php echo nullable_htmlentities($row['contract_template_description']); ?></div>
<div class="mt-1 text-secondary"><?= escapeHtml($row['contract_template_description']) ?></div>
</td>
<td><?php echo $type; ?></td>
<td><?php echo $freq; ?></td>
<td><?php echo "$sla_low_resp / $sla_med_resp / $sla_high_resp"; ?></td>
<td><?php echo "$sla_low_res / $sla_med_res / $sla_high_res"; ?></td>
<td><?php echo $hourly_rate; ?></td>
<td><?php echo $after_hours; ?></td>
<td><?php echo $support_hours; ?></td>
<td><?php echo $net_terms; ?></td>
<td><?php echo $created; ?></td>
<td><?php echo $updated; ?></td>
<td><?= $type ?></td>
<td><?= $freq ?></td>
<td><?= "$sla_low_resp / $sla_med_resp / $sla_high_resp" ?></td>
<td><?= "$sla_low_res / $sla_med_res / $sla_high_res" ?></td>
<td><?= $hourly_rate ?></td>
<td><?= $after_hours ?></td>
<td><?= $support_hours ?></td>
<td><?= $net_terms ?></td>
<td><?= $created ?></td>
<td><?= $updated ?></td>
<td>
<div class="dropdown dropleft text-center">
<button class="btn btn-secondary btn-sm" type="button" data-toggle="dropdown">
@@ -106,7 +106,7 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<i class="fas fa-fw fa-edit mr-2"></i>Edit
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger text-bold" href="post.php?delete_contract_template=<?php echo $id; ?>">
<a class="dropdown-item text-danger text-bold" href="post.php?delete_contract_template=<?= $id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-trash mr-2"></i>Delete
</a>
</div>

208
admin/cron.php Normal file
View File

@@ -0,0 +1,208 @@
<?php
require_once "includes/inc_all_admin.php";
require_once $_SERVER['DOCUMENT_ROOT'] . '/includes/cron_jobs.php';
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_enable_cron, config_cron_last_dispatch_at FROM settings WHERE company_id = 1"));
$config_enable_cron = intval($row['config_enable_cron']);
$cron_last_dispatch_at = $row['config_cron_last_dispatch_at'];
// The dispatcher writes its heartbeat before it runs anything, so anything older than a few
// minutes means the crontab entry itself is missing or failing - a different problem from a
// job that is disabled or erroring, and the one people spend the longest not finding.
$cron_is_running = $cron_last_dispatch_at !== null && (time() - strtotime($cron_last_dispatch_at)) < 300;
$cron_command = "* * * * * php " . dirname(__DIR__) . "/cron/cron.php >/dev/null";
// Registry order is dispatch order, so the table reads the way the cycle runs
$cron_jobs = [];
foreach (cronJobRegistry() as $job) {
$cron_jobs[$job['name']] = $job;
$cron_jobs[$job['name']]['row'] = null;
}
$sql = mysqli_query($mysqli, "SELECT cron_job_daily_at, cron_job_enabled, cron_job_id, cron_job_interval_minutes,
cron_job_last_duration, cron_job_last_error, cron_job_last_error_at, cron_job_last_run_at, cron_job_last_status,
cron_job_name, cron_job_run_now, cron_job_schedule FROM cron_jobs");
while ($job_row = mysqli_fetch_assoc($sql)) {
if (isset($cron_jobs[$job_row['cron_job_name']])) {
$cron_jobs[$job_row['cron_job_name']]['row'] = $job_row;
}
}
?>
<div class="card card-dark">
<div class="card-header py-3">
<h3 class="card-title"><i class="fas fa-fw fa-clock mr-2"></i>Cron</h3>
</div>
<div class="card-body">
<?php if (!$cron_is_running) { ?>
<div class="alert alert-danger">
<h5><i class="fas fa-fw fa-exclamation-triangle mr-2"></i>Cron is not running</h5>
ITFlow last heard from cron <strong><?= escapeHtml(strtolower(cronJobTimeAgo($cron_last_dispatch_at))) ?></strong>.
Nothing below will run - no mail is being sent, no email is being turned into tickets, and invoices are not being generated.
Add this line to the crontab of the user that owns the ITFlow files:
<pre class="bg-dark text-white p-2 mt-2 mb-0"><?= escapeHtml($cron_command) ?></pre>
</div>
<?php } else { ?>
<div class="alert alert-success">
<i class="fas fa-fw fa-check mr-2"></i>Cron last checked in <strong><?= escapeHtml(strtolower(cronJobTimeAgo($cron_last_dispatch_at))) ?></strong>.
<span class="text-muted ml-2"><?= escapeHtml($cron_command) ?></span>
</div>
<?php } ?>
<?php if ($config_enable_cron == 0) { ?>
<div class="alert alert-warning">
<div class="float-right">
<a class="btn btn-sm btn-warning" href="post.php?enable_cron=1&amp;csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-power-off mr-2"></i>Turn cron on
</a>
</div>
<h5><i class="fas fa-fw fa-exclamation-circle mr-2"></i>Cron is switched off</h5>
The dispatcher is running, but every job below stops itself immediately while this is off -
no mail is sent, no email becomes a ticket, and nothing is invoiced.
</div>
<?php } else { ?>
<p class="text-muted">
<small>
<i class="fas fa-fw fa-power-off mr-1"></i>The master switch is <strong>on</strong>. Turning it off
stops every job at once without touching their schedules, which is what you want on a restored
backup or a staging clone - those come up with every job enabled and will otherwise email clients
and charge cards. Switching back on returns you to exactly this configuration.
<a href="post.php?disable_cron=1&amp;csrf_token=<?= $_SESSION['csrf_token'] ?>">Turn cron off</a>.
</small>
</p>
<?php } ?>
<div class="table-responsive-sm">
<table class="table table-borderless table-hover">
<thead class="text-secondary">
<tr>
<th>Job</th>
<th>Schedule</th>
<th>Last Run</th>
<th>Duration</th>
<th>Status</th>
<th>Next Run</th>
<th class="text-center">Action</th>
</tr>
</thead>
<tbody>
<?php foreach ($cron_jobs as $job) {
$job_row = $job['row'];
// A job the dispatcher has not met yet has no row. It gets one on the next
// pass, seeded from the schedule in the registry, so show that meanwhile.
$cron_job_id = $job_row ? intval($job_row['cron_job_id']) : 0;
$enabled = $job_row ? intval($job_row['cron_job_enabled']) : 1;
$schedule = $job_row ? $job_row['cron_job_schedule'] : $job['schedule'];
$interval_minutes = $job_row ? intval($job_row['cron_job_interval_minutes']) : intval($job['interval_minutes'] ?? 1);
$daily_at = $job_row ? $job_row['cron_job_daily_at'] : ($job['daily_at'] ?? null);
$run_now = $job_row ? intval($job_row['cron_job_run_now']) : 0;
$last_run_at = $job_row ? $job_row['cron_job_last_run_at'] : null;
$last_duration = $job_row ? $job_row['cron_job_last_duration'] : null;
$last_status = $job_row ? $job_row['cron_job_last_status'] : null;
$last_error = $job_row ? $job_row['cron_job_last_error'] : null;
$last_error_at = $job_row ? $job_row['cron_job_last_error_at'] : null;
$next_run = $job_row ? cronJobNextRun($job_row) : null;
if ($run_now) {
$status_badge = '<span class="badge badge-warning">Queued</span>';
} elseif ($last_status === 'Running') {
$status_badge = '<span class="badge badge-info">Running</span>';
} elseif ($last_status === 'Completed') {
$status_badge = '<span class="badge badge-success">Completed</span>';
} elseif ($last_status === 'Failed') {
$status_badge = '<span class="badge badge-danger">Failed</span>';
} elseif ($last_status !== null) {
$status_badge = '<span class="badge badge-secondary">Stopped</span>';
} else {
$status_badge = '<span class="badge badge-light">Never run</span>';
}
?>
<tr class="<?= $enabled ? '' : 'text-muted' ?>">
<td>
<strong><?= escapeHtml($job['label']) ?></strong>
<?php if (!$enabled) { ?><span class="badge badge-secondary ml-1">Disabled</span><?php } ?>
<br><small class="text-secondary"><?= escapeHtml($job['description']) ?></small>
<br><small class="text-muted"><code>cron/<?= escapeHtml($job['script']) ?></code></small>
</td>
<td><?= escapeHtml(cronJobScheduleDescription($schedule, $interval_minutes, $daily_at)) ?></td>
<td>
<?= escapeHtml(cronJobTimeAgo($last_run_at)) ?>
<?php if ($last_run_at) { ?><br><small class="text-muted"><?= escapeHtml(date('M j, g:i A', strtotime($last_run_at))) ?></small><?php } ?>
</td>
<td><?= $last_duration === null ? '-' : escapeHtml($last_duration) . 's' ?></td>
<td>
<?= $status_badge ?>
<?php if ($last_status !== null && $last_status !== 'Running' && strlen($last_status) > 9) { ?>
<br><small class="text-muted"><?= escapeHtml($last_status) ?></small>
<?php } ?>
</td>
<td><?= $next_run === null ? '-' : escapeHtml(cronJobTimeAgo($next_run)) ?></td>
<td class="text-center">
<div class="dropdown dropleft text-center">
<button class="btn btn-secondary btn-sm" type="button" data-toggle="dropdown">
<i class="fas fa-ellipsis-v"></i>
</button>
<div class="dropdown-menu">
<a class="dropdown-item <?= $cron_job_id === 0 ? 'disabled' : '' ?>" href="post.php?run_cron_job=<?= $cron_job_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-play mr-2"></i>Run Now
</a>
<button class="dropdown-item ajax-modal <?= $cron_job_id === 0 ? 'disabled' : '' ?>" type="button" data-toggle="ajax-modal"
data-modal-url="modals/cron/cron_edit.php?id=<?= $cron_job_id ?>">
<i class="fas fa-fw fa-edit mr-2"></i>Edit Schedule
</button>
<?php if ($enabled) { ?>
<a class="dropdown-item text-danger" href="post.php?disable_cron_job=<?= $cron_job_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-pause mr-2"></i>Disable
</a>
<?php } else { ?>
<a class="dropdown-item text-success" href="post.php?enable_cron_job=<?= $cron_job_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-play-circle mr-2"></i>Enable
</a>
<?php } ?>
</div>
</div>
</td>
</tr>
<?php if (!empty($last_error)) { ?>
<tr>
<td colspan="7" class="pt-0">
<div class="alert alert-danger mb-0 py-2">
<div class="float-right">
<a class="text-danger" href="post.php?clear_cron_error=<?= $cron_job_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>" title="Dismiss">
<i class="fas fa-fw fa-times"></i>
</a>
</div>
<strong><i class="fas fa-fw fa-exclamation-triangle mr-2"></i>Last error</strong>
<small class="text-muted ml-2"><?= escapeHtml(cronJobTimeAgo($last_error_at)) ?></small>
<div class="mt-1"><small><?= escapeHtml($last_error) ?></small></div>
</div>
</td>
</tr>
<?php } ?>
<?php } ?>
</tbody>
</table>
</div>
<p class="text-muted mb-0">
<small>
<i class="fas fa-fw fa-info-circle mr-1"></i>Run Now does not start the job in your browser - it asks the
dispatcher to pick it up on its next pass, so a job starts within a minute and still runs on the command
line with the same locking as a scheduled run. Detailed per-job output is in
<a href="app_logs.php">App Logs</a>.
</small>
</p>
</div>
</div>
<?php require_once "../includes/footer.php"; ?>

View File

@@ -8,7 +8,8 @@ require_once "includes/inc_all_admin.php";
$sql = mysqli_query(
$mysqli,
"SELECT SQL_CALC_FOUND_ROWS * FROM custom_links
"SELECT SQL_CALC_FOUND_ROWS custom_link_icon, custom_link_id, custom_link_location, custom_link_name,
custom_link_new_tab, custom_link_order, custom_link_uri FROM custom_links
WHERE custom_link_name LIKE '%$q%'
ORDER BY $sort $order LIMIT $record_from, $record_to"
);
@@ -30,7 +31,7 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<div class="col-sm-4 mb-2">
<form autocomplete="off">
<div class="input-group">
<input type="search" class="form-control" name="q" value="<?php if (isset($q)) { echo stripslashes(nullable_htmlentities($q)); } ?>" placeholder="Search Links">
<input type="search" class="form-control" name="q" value="<?php if (isset($q)) { echo stripslashes(escapeHtml($q)); } ?>" placeholder="Search Links">
<div class="input-group-append">
<button class="btn btn-primary"><i class="fa fa-search"></i></button>
</div>
@@ -47,22 +48,22 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<thead class="text-dark <?php if ($num_rows[0] == 0) { echo "d-none"; } ?>">
<tr>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=custom_link_name&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=custom_link_name&order=<?= $disp ?>">
Name <?php if ($sort == 'custom_link_name') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=custom_link_order&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=custom_link_order&order=<?= $disp ?>">
Order <?php if ($sort == 'custom_link_order') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=custom_link_uri&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=custom_link_uri&order=<?= $disp ?>">
URI / <span class="text-secondary">New Tab</span> <?php if ($sort == 'custom_link_uri') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=custom_link_location&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=custom_link_location&order=<?= $disp ?>">
Location <?php if ($sort == 'custom_link_location') { echo $order_icon; } ?>
</a>
</th>
@@ -74,9 +75,9 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
while ($row = mysqli_fetch_assoc($sql)) {
$custom_link_id = intval($row['custom_link_id']);
$custom_link_name = nullable_htmlentities($row['custom_link_name']);
$custom_link_uri = nullable_htmlentities($row['custom_link_uri']);
$custom_link_icon = nullable_htmlentities($row['custom_link_icon']);
$custom_link_name = escapeHtml($row['custom_link_name']);
$custom_link_uri = escapeHtml($row['custom_link_uri']);
$custom_link_icon = escapeHtml($row['custom_link_icon']);
$custom_link_new_tab = intval($row['custom_link_new_tab']);
if ($custom_link_new_tab == 1 ) {
$custom_link_new_tab_display = "<i class='fas fa-fw fa-checkmark'></i>";
@@ -107,12 +108,12 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<td>
<a class="ajax-modal" href="#"
data-modal-url="modals/custom_link/custom_link_edit.php?id=<?= $custom_link_id ?>">
<i class="fa fa-fw fa-<?php echo $custom_link_icon; ?> mr-2"></i><?php echo $custom_link_name;?>
<i class="fa fa-fw fa-<?= $custom_link_icon ?> mr-2"></i><?= $custom_link_name ?>
</a>
</td>
<td><?php echo $custom_link_order_display; ?></td>
<td><?php echo "$custom_link_uri $custom_link_new_tab_display"; ?></td>
<td><?php echo $custom_link_location_display; ?></td>
<td><?= $custom_link_order_display ?></td>
<td><?= "$custom_link_uri $custom_link_new_tab_display" ?></td>
<td><?= $custom_link_location_display ?></td>
<td>
<div class="dropdown dropleft text-center">
<button class="btn btn-secondary btn-sm" type="button" data-toggle="dropdown">
@@ -123,7 +124,7 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<i class="fas fa-fw fa-edit mr-2"></i>Edit
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger text-bold confirm-link" href="post.php?delete_custom_link=<?php echo $custom_link_id; ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<a class="dropdown-item text-danger text-bold confirm-link" href="post.php?delete_custom_link=<?= $custom_link_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-trash mr-2"></i>Delete
</a>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,184 @@
<?php
/*
* ITFlow - Database update to version 2.0.0 (from 1.9.9)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "RENAME TABLE `logins` TO `credentials`");
mysqli_query($mysqli, "
ALTER TABLE `credentials`
CHANGE COLUMN `login_id` `credential_id` INT(11) NOT NULL AUTO_INCREMENT,
CHANGE COLUMN `login_name` `credential_name` VARCHAR(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
CHANGE COLUMN `login_description` `credential_description` VARCHAR(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
CHANGE COLUMN `login_category` `credential_category` VARCHAR(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
CHANGE COLUMN `login_uri` `credential_uri` VARCHAR(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
CHANGE COLUMN `login_uri_2` `credential_uri_2` VARCHAR(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
CHANGE COLUMN `login_username` `credential_username` VARCHAR(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
CHANGE COLUMN `login_password` `credential_password` VARBINARY(200) NULL DEFAULT NULL,
CHANGE COLUMN `login_otp_secret` `credential_otp_secret` VARCHAR(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
CHANGE COLUMN `login_note` `credential_note` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
CHANGE COLUMN `login_important` `credential_important` TINYINT(1) NOT NULL DEFAULT '0',
CHANGE COLUMN `login_created_at` `credential_created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP(),
CHANGE COLUMN `login_updated_at` `credential_updated_at` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP(),
CHANGE COLUMN `login_archived_at` `credential_archived_at` DATETIME NULL DEFAULT NULL,
CHANGE COLUMN `login_accessed_at` `credential_accessed_at` DATETIME NULL DEFAULT NULL,
CHANGE COLUMN `login_password_changed_at` `credential_password_changed_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP(),
CHANGE COLUMN `login_folder_id` `credential_folder_id` INT(11) NOT NULL DEFAULT '0',
CHANGE COLUMN `login_contact_id` `credential_contact_id` INT(11) NOT NULL DEFAULT '0',
CHANGE COLUMN `login_asset_id` `credential_asset_id` INT(11) NOT NULL DEFAULT '0',
CHANGE COLUMN `login_client_id` `credential_client_id` INT(11) NOT NULL DEFAULT '0'
");
// Rename table contact_logins to contact_credentials
mysqli_query($mysqli, "RENAME TABLE `contact_logins` TO `contact_credentials`");
// Alter contact_credentials table and change login_id to credential_id
mysqli_query($mysqli, "
ALTER TABLE `contact_credentials`
CHANGE COLUMN `login_id` `credential_id` INT(11) NOT NULL
");
// Clean up orphaned contact_id rows in contact_credentials
mysqli_query($mysqli, "
DELETE FROM `contact_credentials`
WHERE `contact_id` NOT IN (SELECT `contact_id` FROM `contacts`);
");
// Clean up orphaned credential_id rows in contact_credentials
mysqli_query($mysqli, "
DELETE FROM `contact_credentials`
WHERE `credential_id` NOT IN (SELECT `credential_id` FROM `credentials`);
");
// Add foreign keys to contact_credentials
mysqli_query($mysqli, "
ALTER TABLE `contact_credentials`
ADD FOREIGN KEY (`contact_id`) REFERENCES `contacts`(`contact_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`credential_id`) REFERENCES `credentials`(`credential_id`) ON DELETE CASCADE
");
// Rename table service_logins to service_credentials
mysqli_query($mysqli, "RENAME TABLE `service_logins` TO `service_credentials`");
// Alter service_credentials table and change login_id to credential_id
mysqli_query($mysqli, "
ALTER TABLE `service_credentials`
CHANGE COLUMN `login_id` `credential_id` INT(11) NOT NULL
");
// Clean up orphaned service_id rows in service_credentials
mysqli_query($mysqli, "
DELETE FROM `service_credentials`
WHERE `service_id` NOT IN (SELECT `service_id` FROM `services`);
");
// Clean up orphaned credential_id rows in service_credentials
mysqli_query($mysqli, "
DELETE FROM `service_credentials`
WHERE `credential_id` NOT IN (SELECT `credential_id` FROM `credentials`);
");
// Add foreign keys to service_credentials
mysqli_query($mysqli, "
ALTER TABLE `service_credentials`
ADD FOREIGN KEY (`service_id`) REFERENCES `services`(`service_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`credential_id`) REFERENCES `credentials`(`credential_id`) ON DELETE CASCADE
");
// Rename table software_logins to software_credentials
mysqli_query($mysqli, "RENAME TABLE `software_logins` TO `software_credentials`");
// Alter software_credentials table and change login_id to credential_id
mysqli_query($mysqli, "
ALTER TABLE `software_credentials`
CHANGE COLUMN `login_id` `credential_id` INT(11) NOT NULL
");
// Clean up orphaned software_id rows in software_credentials
mysqli_query($mysqli, "
DELETE FROM `software_credentials`
WHERE `software_id` NOT IN (SELECT `software_id` FROM `software`);
");
// Clean up orphaned credential_id rows in software_credentials
mysqli_query($mysqli, "
DELETE FROM `software_credentials`
WHERE `credential_id` NOT IN (SELECT `credential_id` FROM `credentials`);
");
// Add foreign keys to software_credentials
mysqli_query($mysqli, "
ALTER TABLE `software_credentials`
ADD FOREIGN KEY (`software_id`) REFERENCES `software`(`software_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`credential_id`) REFERENCES `credentials`(`credential_id`) ON DELETE CASCADE
");
// Rename table vendor_logins to vendor_credentials
mysqli_query($mysqli, "RENAME TABLE `vendor_logins` TO `vendor_credentials`");
// Alter vendor_credentials table and change login_id to credential_id
mysqli_query($mysqli, "
ALTER TABLE `vendor_credentials`
CHANGE COLUMN `login_id` `credential_id` INT(11) NOT NULL
");
// Clean up orphaned vendor_id rows in vendor_credentials
mysqli_query($mysqli, "
DELETE FROM `vendor_credentials`
WHERE `vendor_id` NOT IN (SELECT `vendor_id` FROM `vendors`);
");
// Clean up orphaned credential_id rows in vendor_credentials
mysqli_query($mysqli, "
DELETE FROM `vendor_credentials`
WHERE `credential_id` NOT IN (SELECT `credential_id` FROM `credentials`);
");
// Add foreign keys to vendor_credentials
mysqli_query($mysqli, "
ALTER TABLE `vendor_credentials`
ADD FOREIGN KEY (`vendor_id`) REFERENCES `vendors`(`vendor_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`credential_id`) REFERENCES `credentials`(`credential_id`) ON DELETE CASCADE
");
// Rename table login_tags to credential_tags
mysqli_query($mysqli, "RENAME TABLE `login_tags` TO `credential_tags`");
// Alter credential_tags table and change login_id to credential_id
mysqli_query($mysqli, "
ALTER TABLE `credential_tags`
CHANGE COLUMN `login_id` `credential_id` INT(11) NOT NULL
");
// Clean up orphaned tag_id rows in credential_tags
mysqli_query($mysqli, "
DELETE FROM `credential_tags`
WHERE `tag_id` NOT IN (SELECT `tag_id` FROM `tags`);
");
// Clean up orphaned credential_id rows in credential_tags
mysqli_query($mysqli, "
DELETE FROM `credential_tags`
WHERE `credential_id` NOT IN (SELECT `credential_id` FROM `credentials`);
");
// Add foreign keys to credential_tags
mysqli_query($mysqli, "
ALTER TABLE `credential_tags`
ADD FOREIGN KEY (`tag_id`) REFERENCES `tags`(`tag_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`credential_id`) REFERENCES `credentials`(`credential_id`) ON DELETE CASCADE
");
// Create asset_credentials table with foreign keys
mysqli_query($mysqli, "
CREATE TABLE `asset_credentials` (
`credential_id` INT(11) NOT NULL,
`asset_id` INT(11) NOT NULL,
PRIMARY KEY (`credential_id`, `asset_id`),
FOREIGN KEY (`credential_id`) REFERENCES `credentials`(`credential_id`) ON DELETE CASCADE,
FOREIGN KEY (`asset_id`) REFERENCES `assets`(`asset_id`) ON DELETE CASCADE
)
");

View File

@@ -0,0 +1,15 @@
<?php
/*
* ITFlow - Database update to version 2.0.1 (from 2.0.0)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
//Dropping patch panel as a patch panel can be documented as an asset with interfaces.
mysqli_query($mysqli, "DROP TABLE `patch_panel_ports`");
mysqli_query($mysqli, "DROP TABLE `patch_panels`");
mysqli_query($mysqli, "RENAME TABLE `events` TO `calendar_events`");
mysqli_query($mysqli, "RENAME TABLE `event_attendees` TO `calendar_event_attendees`");

View File

@@ -0,0 +1,153 @@
<?php
/*
* ITFlow - Database update to version 2.0.2 (from 2.0.1)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Clean up orphaned data before adding foreign keys
// Clean up orphaned asset_custom_asset_id rows in asset_custom
mysqli_query($mysqli, "
DELETE FROM `asset_custom`
WHERE `asset_custom_asset_id` NOT IN (SELECT `asset_id` FROM `assets`);
");
// Add foreign key to asset_custom
mysqli_query($mysqli, "
ALTER TABLE `asset_custom`
ADD FOREIGN KEY (`asset_custom_asset_id`) REFERENCES `assets`(`asset_id`) ON DELETE CASCADE
");
// Clean up orphaned asset_id rows in asset_documents
mysqli_query($mysqli, "
DELETE FROM `asset_documents`
WHERE `asset_id` NOT IN (SELECT `asset_id` FROM `assets`);
");
// Clean up orphaned document_id rows in asset_documents
mysqli_query($mysqli, "
DELETE FROM `asset_documents`
WHERE `document_id` NOT IN (SELECT `document_id` FROM `documents`);
");
// Add foreign keys to asset_documents
mysqli_query($mysqli, "
ALTER TABLE `asset_documents`
ADD FOREIGN KEY (`asset_id`) REFERENCES `assets`(`asset_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`document_id`) REFERENCES `documents`(`document_id`) ON DELETE CASCADE
");
// Clean up orphaned asset_id rows in asset_files
mysqli_query($mysqli, "
DELETE FROM `asset_files`
WHERE `asset_id` NOT IN (SELECT `asset_id` FROM `assets`);
");
// Clean up orphaned file_id rows in asset_files
mysqli_query($mysqli, "
DELETE FROM `asset_files`
WHERE `file_id` NOT IN (SELECT `file_id` FROM `files`);
");
// Add foreign keys to asset_files
mysqli_query($mysqli, "
ALTER TABLE `asset_files`
ADD FOREIGN KEY (`asset_id`) REFERENCES `assets`(`asset_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`file_id`) REFERENCES `files`(`file_id`) ON DELETE CASCADE
");
// Clean up orphaned asset_history_asset_id rows in asset_history
mysqli_query($mysqli, "
DELETE FROM `asset_history`
WHERE `asset_history_asset_id` NOT IN (SELECT `asset_id` FROM `assets`);
");
// Add foreign key to asset_history
mysqli_query($mysqli, "
ALTER TABLE `asset_history`
ADD FOREIGN KEY (`asset_history_asset_id`) REFERENCES `assets`(`asset_id`) ON DELETE CASCADE
");
// Clean up orphaned interface_asset_id rows in asset_interfaces
mysqli_query($mysqli, "
DELETE FROM `asset_interfaces`
WHERE `interface_asset_id` NOT IN (SELECT `asset_id` FROM `assets`);
");
// Add foreign key to asset_interfaces
mysqli_query($mysqli, "
ALTER TABLE `asset_interfaces`
ADD FOREIGN KEY (`interface_asset_id`) REFERENCES `assets`(`asset_id`) ON DELETE CASCADE
");
// Clean up orphaned asset_note_asset_id rows in asset_notes
mysqli_query($mysqli, "
DELETE FROM `asset_notes`
WHERE `asset_note_asset_id` NOT IN (SELECT `asset_id` FROM `assets`);
");
// Add foreign key to asset_notes
mysqli_query($mysqli, "
ALTER TABLE `asset_notes`
ADD FOREIGN KEY (`asset_note_asset_id`) REFERENCES `assets`(`asset_id`) ON DELETE CASCADE
");
// Clean up orphaned contact_id rows in contact_assets
mysqli_query($mysqli, "
DELETE FROM `contact_assets`
WHERE `contact_id` NOT IN (SELECT `contact_id` FROM `contacts`);
");
// Clean up orphaned asset_id rows in contact_assets
mysqli_query($mysqli, "
DELETE FROM `contact_assets`
WHERE `asset_id` NOT IN (SELECT `asset_id` FROM `assets`);
");
// Add foreign keys to contact_assets
mysqli_query($mysqli, "
ALTER TABLE `contact_assets`
ADD FOREIGN KEY (`contact_id`) REFERENCES `contacts`(`contact_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`asset_id`) REFERENCES `assets`(`asset_id`) ON DELETE CASCADE
");
// Clean up orphaned service_id rows in service_assets
mysqli_query($mysqli, "
DELETE FROM `service_assets`
WHERE `service_id` NOT IN (SELECT `service_id` FROM `services`);
");
// Clean up orphaned asset_id rows in service_assets
mysqli_query($mysqli, "
DELETE FROM `service_assets`
WHERE `asset_id` NOT IN (SELECT `asset_id` FROM `assets`);
");
// Add foreign keys to service_assets
mysqli_query($mysqli, "
ALTER TABLE `service_assets`
ADD FOREIGN KEY (`service_id`) REFERENCES `services`(`service_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`asset_id`) REFERENCES `assets`(`asset_id`) ON DELETE CASCADE
");
// Clean up orphaned software_id rows in software_assets
mysqli_query($mysqli, "
DELETE FROM `software_assets`
WHERE `software_id` NOT IN (SELECT `software_id` FROM `software`);
");
// Clean up orphaned asset_id rows in software_assets
mysqli_query($mysqli, "
DELETE FROM `software_assets`
WHERE `asset_id` NOT IN (SELECT `asset_id` FROM `assets`);
");
// Add foreign keys to software_assets
mysqli_query($mysqli, "
ALTER TABLE `software_assets`
ADD FOREIGN KEY (`software_id`) REFERENCES `software`(`software_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`asset_id`) REFERENCES `assets`(`asset_id`) ON DELETE CASCADE
");

View File

@@ -0,0 +1,31 @@
<?php
/*
* ITFlow - Database update to version 2.0.3 (from 2.0.2)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Clean up orphans
mysqli_query($mysqli, "
DELETE FROM `calendar_event_attendees`
WHERE `attendee_event_id` NOT IN (SELECT `event_id` FROM `calendar_events`);
");
mysqli_query($mysqli, "
DELETE FROM `calendar_events`
WHERE `event_calendar_id` NOT IN (SELECT `calendar_id` FROM `calendars`);
");
// Add foreign key to calendar_event_attendees
mysqli_query($mysqli, "
ALTER TABLE `calendar_event_attendees`
ADD FOREIGN KEY (`attendee_event_id`) REFERENCES `calendar_events`(`event_id`) ON DELETE CASCADE
");
// Add foreign key to calendar_events
mysqli_query($mysqli, "
ALTER TABLE `calendar_events`
ADD FOREIGN KEY (`event_calendar_id`) REFERENCES `calendars`(`calendar_id`) ON DELETE CASCADE
");

View File

@@ -0,0 +1,20 @@
<?php
/*
* ITFlow - Database update to version 2.0.4 (from 2.0.3)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Clean up orphaned history
mysqli_query($mysqli, "
DELETE FROM `certificate_history`
WHERE `certificate_history_certificate_id` NOT IN (SELECT `certificate_id` FROM `certificates`);
");
// Add foreign key certificate history
mysqli_query($mysqli, "
ALTER TABLE `certificate_history`
ADD FOREIGN KEY (`certificate_history_certificate_id`) REFERENCES `certificates`(`certificate_id`) ON DELETE CASCADE
");

View File

@@ -0,0 +1,364 @@
<?php
/*
* ITFlow - Database update to version 2.0.5 (from 2.0.4)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Clean up orphaned history
mysqli_query($mysqli, "
DELETE FROM `client_notes`
WHERE `client_note_client_id` NOT IN (SELECT `client_id` FROM `clients`);
");
// Add foreign key
mysqli_query($mysqli, "
ALTER TABLE `client_notes`
ADD FOREIGN KEY (`client_note_client_id`) REFERENCES `clients`(`client_id`) ON DELETE CASCADE
");
// Clean up orphaned history
mysqli_query($mysqli, "
DELETE FROM `client_tags`
WHERE `client_id` NOT IN (SELECT `client_id` FROM `clients`);
");
// Clean up orphaned history
mysqli_query($mysqli, "
DELETE FROM `client_tags`
WHERE `tag_id` NOT IN (SELECT `tag_id` FROM `tags`);
");
// Add foreign key
mysqli_query($mysqli, "
ALTER TABLE `client_tags`
ADD FOREIGN KEY (`client_id`) REFERENCES `clients`(`client_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`tag_id`) REFERENCES `tags`(`tag_id`) ON DELETE CASCADE
");
//Contact Assets
// Clean up orphaned history
mysqli_query($mysqli, "
DELETE FROM `contact_assets`
WHERE `contact_id` NOT IN (SELECT `contact_id` FROM `contacts`);
");
mysqli_query($mysqli, "
DELETE FROM `contact_assets`
WHERE `asset_id` NOT IN (SELECT `asset_id` FROM `assets`);
");
// Add foreign key
mysqli_query($mysqli, "
ALTER TABLE `contact_assets`
ADD FOREIGN KEY (`contact_id`) REFERENCES `contacts`(`contact_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`asset_id`) REFERENCES `assets`(`asset_id`) ON DELETE CASCADE
");
// Contact Documents
// Clean up orphaned history
mysqli_query($mysqli, "
DELETE FROM `contact_documents`
WHERE `contact_id` NOT IN (SELECT `contact_id` FROM `contacts`);
");
mysqli_query($mysqli, "
DELETE FROM `contact_documents`
WHERE `document_id` NOT IN (SELECT `document_id` FROM `documents`);
");
// Add foreign key
mysqli_query($mysqli, "
ALTER TABLE `contact_documents`
ADD FOREIGN KEY (`contact_id`) REFERENCES `contacts`(`contact_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`document_id`) REFERENCES `documents`(`document_id`) ON DELETE CASCADE
");
// contact_files
// Clean up orphaned rows
mysqli_query($mysqli, "
DELETE FROM `contact_files`
WHERE `contact_id` NOT IN (SELECT `contact_id` FROM `contacts`);
");
mysqli_query($mysqli, "
DELETE FROM `contact_files`
WHERE `file_id` NOT IN (SELECT `file_id` FROM `files`);
");
// Add foreign key
mysqli_query($mysqli, "
ALTER TABLE `contact_files`
ADD FOREIGN KEY (`contact_id`) REFERENCES `contacts`(`contact_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`file_id`) REFERENCES `files`(`file_id`) ON DELETE CASCADE
");
// contact_notes
// Clean up orphaned rows
mysqli_query($mysqli, "
DELETE FROM `contact_notes`
WHERE `contact_note_contact_id` NOT IN (SELECT `contact_id` FROM `contacts`);
");
// Add foreign key
mysqli_query($mysqli, "
ALTER TABLE `contact_notes`
ADD FOREIGN KEY (`contact_note_contact_id`) REFERENCES `contacts`(`contact_id`) ON DELETE CASCADE
");
// contact_tags
// Clean up orphaned rows
mysqli_query($mysqli, "
DELETE FROM `contact_tags`
WHERE `contact_id` NOT IN (SELECT `contact_id` FROM `contacts`);
");
mysqli_query($mysqli, "
DELETE FROM `contact_tags`
WHERE `tag_id` NOT IN (SELECT `tag_id` FROM `tags`);
");
// Add foreign key
mysqli_query($mysqli, "
ALTER TABLE `contact_tags`
ADD FOREIGN KEY (`contact_id`) REFERENCES `contacts`(`contact_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`tag_id`) REFERENCES `tags`(`tag_id`) ON DELETE CASCADE
");
// document_files
// Clean up orphaned rows
mysqli_query($mysqli, "
DELETE FROM `document_files`
WHERE `document_id` NOT IN (SELECT `document_id` FROM `documents`);
");
mysqli_query($mysqli, "
DELETE FROM `document_files`
WHERE `file_id` NOT IN (SELECT `file_id` FROM `files`);
");
// Add foreign key
mysqli_query($mysqli, "
ALTER TABLE `document_files`
ADD FOREIGN KEY (`document_id`) REFERENCES `documents`(`document_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`file_id`) REFERENCES `files`(`file_id`) ON DELETE CASCADE
");
// domain_history
// Clean up orphaned rows
mysqli_query($mysqli, "
DELETE FROM `domain_history`
WHERE `domain_history_domain_id` NOT IN (SELECT `domain_id` FROM `domains`);
");
// Add foreign key
mysqli_query($mysqli, "
ALTER TABLE `domain_history`
ADD FOREIGN KEY (`domain_history_domain_id`) REFERENCES `domains`(`domain_id`) ON DELETE CASCADE
");
// location_tags
// Clean up orphaned rows
mysqli_query($mysqli, "
DELETE FROM `location_tags`
WHERE `location_id` NOT IN (SELECT `location_id` FROM `locations`);
");
mysqli_query($mysqli, "
DELETE FROM `location_tags`
WHERE `tag_id` NOT IN (SELECT `tag_id` FROM `tags`);
");
// Add foreign key
mysqli_query($mysqli, "
ALTER TABLE `location_tags`
ADD FOREIGN KEY (`location_id`) REFERENCES `locations`(`location_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`tag_id`) REFERENCES `tags`(`tag_id`) ON DELETE CASCADE
");
// quote_files
// Clean up orphaned rows
mysqli_query($mysqli, "
DELETE FROM `quote_files`
WHERE `quote_id` NOT IN (SELECT `quote_id` FROM `quotes`);
");
mysqli_query($mysqli, "
DELETE FROM `quote_files`
WHERE `file_id` NOT IN (SELECT `file_id` FROM `files`);
");
// Add foreign key
mysqli_query($mysqli, "
ALTER TABLE `quote_files`
ADD FOREIGN KEY (`quote_id`) REFERENCES `quotes`(`quote_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`file_id`) REFERENCES `files`(`file_id`) ON DELETE CASCADE
");
// service_certificates
// Clean up orphaned rows
mysqli_query($mysqli, "
DELETE FROM `service_certificates`
WHERE `service_id` NOT IN (SELECT `service_id` FROM `services`);
");
mysqli_query($mysqli, "
DELETE FROM `service_certificates`
WHERE `certificate_id` NOT IN (SELECT `certificate_id` FROM `certificates`);
");
// Add foreign key
mysqli_query($mysqli, "
ALTER TABLE `service_certificates`
ADD FOREIGN KEY (`service_id`) REFERENCES `services`(`service_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`certificate_id`) REFERENCES `certificates`(`certificate_id`) ON DELETE CASCADE
");
// service_contacts
// Clean up orphaned rows
mysqli_query($mysqli, "
DELETE FROM `service_contacts`
WHERE `service_id` NOT IN (SELECT `service_id` FROM `services`);
");
mysqli_query($mysqli, "
DELETE FROM `service_contacts`
WHERE `contact_id` NOT IN (SELECT `contact_id` FROM `contacts`);
");
// Add foreign key
mysqli_query($mysqli, "
ALTER TABLE `service_contacts`
ADD FOREIGN KEY (`service_id`) REFERENCES `services`(`service_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`contact_id`) REFERENCES `contacts`(`contact_id`) ON DELETE CASCADE
");
// service_documents
// Clean up orphaned rows
mysqli_query($mysqli, "
DELETE FROM `service_documents`
WHERE `service_id` NOT IN (SELECT `service_id` FROM `services`);
");
mysqli_query($mysqli, "
DELETE FROM `service_documents`
WHERE `document_id` NOT IN (SELECT `document_id` FROM `documents`);
");
// Add foreign key
mysqli_query($mysqli, "
ALTER TABLE `service_documents`
ADD FOREIGN KEY (`service_id`) REFERENCES `services`(`service_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`document_id`) REFERENCES `documents`(`document_id`) ON DELETE CASCADE
");
// service_domains
// Clean up orphaned rows
mysqli_query($mysqli, "
DELETE FROM `service_domains`
WHERE `service_id` NOT IN (SELECT `service_id` FROM `services`);
");
mysqli_query($mysqli, "
DELETE FROM `service_domains`
WHERE `domain_id` NOT IN (SELECT `domain_id` FROM `domains`);
");
// Add foreign key
mysqli_query($mysqli, "
ALTER TABLE `service_domains`
ADD FOREIGN KEY (`service_id`) REFERENCES `services`(`service_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`domain_id`) REFERENCES `domains`(`domain_id`) ON DELETE CASCADE
");
// service_vendors
// Clean up orphaned rows
mysqli_query($mysqli, "
DELETE FROM `service_vendors`
WHERE `service_id` NOT IN (SELECT `service_id` FROM `services`);
");
mysqli_query($mysqli, "
DELETE FROM `service_vendors`
WHERE `vendor_id` NOT IN (SELECT `vendor_id` FROM `vendors`);
");
// Add foreign key
mysqli_query($mysqli, "
ALTER TABLE `service_vendors`
ADD FOREIGN KEY (`service_id`) REFERENCES `services`(`service_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`vendor_id`) REFERENCES `vendors`(`vendor_id`) ON DELETE CASCADE
");
// software_contacts
// Clean up orphaned rows
mysqli_query($mysqli, "
DELETE FROM `software_contacts`
WHERE `software_id` NOT IN (SELECT `software_id` FROM `software`);
");
mysqli_query($mysqli, "
DELETE FROM `software_contacts`
WHERE `contact_id` NOT IN (SELECT `contact_id` FROM `contacts`);
");
// Add foreign key
mysqli_query($mysqli, "
ALTER TABLE `software_contacts`
ADD FOREIGN KEY (`software_id`) REFERENCES `software`(`software_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`contact_id`) REFERENCES `contacts`(`contact_id`) ON DELETE CASCADE
");
// software_documents
// Clean up orphaned rows
mysqli_query($mysqli, "
DELETE FROM `software_documents`
WHERE `software_id` NOT IN (SELECT `software_id` FROM `software`);
");
mysqli_query($mysqli, "
DELETE FROM `software_documents`
WHERE `document_id` NOT IN (SELECT `document_id` FROM `documents`);
");
// Add foreign key
mysqli_query($mysqli, "
ALTER TABLE `software_documents`
ADD FOREIGN KEY (`software_id`) REFERENCES `software`(`software_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`document_id`) REFERENCES `documents`(`document_id`) ON DELETE CASCADE
");
// software_files
// Clean up orphaned rows
mysqli_query($mysqli, "
DELETE FROM `software_files`
WHERE `software_id` NOT IN (SELECT `software_id` FROM `software`);
");
mysqli_query($mysqli, "
DELETE FROM `software_files`
WHERE `file_id` NOT IN (SELECT `file_id` FROM `files`);
");
// Add foreign key
mysqli_query($mysqli, "
ALTER TABLE `software_files`
ADD FOREIGN KEY (`software_id`) REFERENCES `software`(`software_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`file_id`) REFERENCES `files`(`file_id`) ON DELETE CASCADE
");
// vendor_documents
// Clean up orphaned rows
mysqli_query($mysqli, "
DELETE FROM `vendor_documents`
WHERE `vendor_id` NOT IN (SELECT `vendor_id` FROM `vendors`);
");
mysqli_query($mysqli, "
DELETE FROM `vendor_documents`
WHERE `document_id` NOT IN (SELECT `document_id` FROM `documents`);
");
// Add foreign key
mysqli_query($mysqli, "
ALTER TABLE `vendor_documents`
ADD FOREIGN KEY (`vendor_id`) REFERENCES `vendors`(`vendor_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`document_id`) REFERENCES `documents`(`document_id`) ON DELETE CASCADE
");
// vendor_files
// Clean up orphaned rows
mysqli_query($mysqli, "
DELETE FROM `vendor_files`
WHERE `vendor_id` NOT IN (SELECT `vendor_id` FROM `vendors`);
");
mysqli_query($mysqli, "
DELETE FROM `vendor_files`
WHERE `file_id` NOT IN (SELECT `file_id` FROM `files`);
");
// Add foreign key
mysqli_query($mysqli, "
ALTER TABLE `vendor_files`
ADD FOREIGN KEY (`vendor_id`) REFERENCES `vendors`(`vendor_id`) ON DELETE CASCADE,
ADD FOREIGN KEY (`file_id`) REFERENCES `files`(`file_id`) ON DELETE CASCADE
");

View File

@@ -0,0 +1,36 @@
<?php
/*
* ITFlow - Database update to version 2.0.6 (from 2.0.5)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// CONVERT All tables TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci
$tables = [
'accounts', 'api_keys', 'app_logs', 'asset_credentials', 'asset_custom', 'asset_documents',
'asset_files', 'asset_history', 'asset_interface_links', 'asset_interfaces', 'asset_notes', 'assets',
'auth_logs', 'budget', 'calendar_event_attendees', 'calendar_events', 'calendars', 'categories',
'certificate_history', 'certificates', 'client_notes', 'client_stripe', 'client_tags', 'clients',
'companies', 'contact_assets', 'contact_credentials', 'contact_documents', 'contact_files', 'contact_notes',
'contact_tags', 'contacts', 'credential_tags', 'credentials', 'custom_fields', 'custom_links',
'custom_values', 'document_files', 'documents', 'domain_history', 'domains', 'email_queue', 'expenses',
'files', 'folders', 'history', 'invoice_items', 'invoices', 'location_tags', 'locations', 'logs',
'modules', 'networks', 'notifications', 'payments', 'products', 'project_template_ticket_templates',
'project_templates', 'projects', 'quote_files', 'quotes', 'rack_units', 'racks', 'records',
'recurring_expenses', 'recurring_invoices', 'recurring_payments', 'recurring_ticket_assets', 'recurring_tickets',
'remember_tokens', 'revenues', 'service_assets', 'service_certificates', 'service_contacts', 'service_credentials',
'service_documents', 'service_domains', 'service_vendors', 'services', 'settings', 'shared_items',
'software', 'software_assets', 'software_contacts', 'software_credentials', 'software_documents', 'software_files',
'tags', 'task_templates', 'tasks', 'taxes', 'ticket_assets', 'ticket_attachments', 'ticket_history', 'ticket_replies',
'ticket_statuses', 'ticket_templates', 'ticket_views', 'ticket_watchers', 'tickets', 'transfers', 'trips',
'user_client_permissions', 'user_role_permissions', 'user_roles', 'user_settings', 'users', 'vendor_credentials',
'vendor_documents', 'vendor_files', 'vendors'
];
foreach ($tables as $table) {
$sql = "ALTER TABLE `$table` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;";
mysqli_query($mysqli, $sql);
}

View File

@@ -0,0 +1,11 @@
<?php
/*
* ITFlow - Database update to version 2.0.7 (from 2.0.6)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Fix service_domains to yse InnoDB instead of MyISAM
mysqli_query($mysqli, "ALTER TABLE service_domains ENGINE = InnoDB;");

View File

@@ -0,0 +1,10 @@
<?php
/*
* ITFlow - Database update to version 2.0.8 (from 2.0.7)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "ALTER TABLE `files` DROP `file_hash`");

View File

@@ -0,0 +1,12 @@
<?php
/*
* ITFlow - Database update to version 2.0.9 (from 2.0.8)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "ALTER TABLE `files` DROP `file_has_thumbnail`");
mysqli_query($mysqli, "ALTER TABLE `files` DROP `file_has_preview`");
mysqli_query($mysqli, "ALTER TABLE `files` DROP `file_asset_id`");

View File

@@ -0,0 +1,19 @@
<?php
/*
* ITFlow - Database update to version 2.1.0 (from 2.0.9)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "ALTER TABLE `contacts` ADD `contact_phone_country_code` VARCHAR(10) DEFAULT 1 AFTER `contact_email`");
mysqli_query($mysqli, "ALTER TABLE `contacts` ADD `contact_mobile_country_code` VARCHAR(10) DEFAULT 1 AFTER `contact_extension`");
mysqli_query($mysqli, "ALTER TABLE `locations` ADD `location_phone_country_code` VARCHAR(10) DEFAULT 1 AFTER `location_zip`");
mysqli_query($mysqli, "ALTER TABLE `locations` ADD `location_phone_extension` VARCHAR(10) DEFAULT NULL AFTER `location_phone`");
mysqli_query($mysqli, "ALTER TABLE `locations` ADD `location_fax_country_code` VARCHAR(10) DEFAULT 1 AFTER `location_phone_extension`");
mysqli_query($mysqli, "ALTER TABLE `vendors` ADD `vendor_phone_country_code` VARCHAR(10) DEFAULT 1 AFTER `vendor_contact_name`");
mysqli_query($mysqli, "ALTER TABLE `companies` ADD `company_phone_country_code` VARCHAR(10) DEFAULT 1 AFTER `company_country`");

View File

@@ -0,0 +1,10 @@
<?php
/*
* ITFlow - Database update to version 2.1.1 (from 2.1.0)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "ALTER TABLE `user_settings` ADD `user_config_signature` TEXT DEFAULT NULL AFTER `user_config_calendar_first_day`");

View File

@@ -0,0 +1,10 @@
<?php
/*
* ITFlow - Database update to version 2.1.2 (from 2.1.1)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "ALTER TABLE `settings` DROP `config_phone_mask`");

View File

@@ -0,0 +1,36 @@
<?php
/*
* ITFlow - Database update to version 2.1.3 (from 2.1.2)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Update country_code to NULL for `contacts` table
mysqli_query($mysqli, "ALTER TABLE `contacts` MODIFY `contact_phone_country_code` VARCHAR(10) DEFAULT NULL");
mysqli_query($mysqli, "ALTER TABLE `contacts` MODIFY `contact_mobile_country_code` VARCHAR(10) DEFAULT NULL");
// Update country_code to NULL for `locations` table
mysqli_query($mysqli, "ALTER TABLE `locations` MODIFY `location_phone_country_code` VARCHAR(10) DEFAULT NULL");
mysqli_query($mysqli, "ALTER TABLE `locations` MODIFY `location_fax_country_code` VARCHAR(10) DEFAULT NULL");
// Update country_code to NULL for `vendors` table
mysqli_query($mysqli, "ALTER TABLE `vendors` MODIFY `vendor_phone_country_code` VARCHAR(10) DEFAULT NULL");
// Update country_code to NULL for `companies` table
mysqli_query($mysqli, "ALTER TABLE `companies` MODIFY `company_phone_country_code` VARCHAR(10) DEFAULT NULL");
// Set country_code to NULL for `contacts` table
mysqli_query($mysqli, "UPDATE `contacts` SET `contact_phone_country_code` = NULL");
mysqli_query($mysqli, "UPDATE `contacts` SET `contact_mobile_country_code` = NULL");
// Set country_code to NULL for `locations` table
mysqli_query($mysqli, "UPDATE `locations` SET `location_phone_country_code` = NULL");
mysqli_query($mysqli, "UPDATE `locations` SET `location_fax_country_code` = NULL");
// Set country_code to NULL for `vendors` table
mysqli_query($mysqli, "UPDATE `vendors` SET `vendor_phone_country_code` = NULL");
// Set country_code to NULL for `companies` table
mysqli_query($mysqli, "UPDATE `companies` SET `company_phone_country_code` = NULL");

View File

@@ -0,0 +1,11 @@
<?php
/*
* ITFlow - Database update to version 2.1.4 (from 2.1.3)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "ALTER TABLE `client_stripe` ADD `stripe_pm_details` VARCHAR(200) DEFAULT NULL AFTER `stripe_pm`");
mysqli_query($mysqli, "ALTER TABLE `client_stripe` ADD `stripe_pm_created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER `stripe_pm_details`");

View File

@@ -0,0 +1,13 @@
<?php
/*
* ITFlow - Database update to version 2.1.5 (from 2.1.4)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "ALTER TABLE `settings` ADD `config_ticket_timer_autostart` TINYINT(1) NOT NULL DEFAULT '0' AFTER `config_ticket_default_billable`");
mysqli_query($mysqli, "ALTER TABLE `tickets` ADD `ticket_due_at` DATETIME DEFAULT NULL AFTER `ticket_updated_at`");
mysqli_query($mysqli, "ALTER TABLE `companies` ADD `company_tax_id` VARCHAR(200) DEFAULT NULL AFTER `company_currency`");
mysqli_query($mysqli, "ALTER TABLE `settings` ADD `config_invoice_show_tax_id` TINYINT(1) NOT NULL DEFAULT '0' AFTER `config_invoice_paid_notification_email`");

View File

@@ -0,0 +1,27 @@
<?php
/*
* ITFlow - Database update to version 2.1.6 (from 2.1.5)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "CREATE TABLE `document_versions` (
`document_version_id` INT(11) NOT NULL AUTO_INCREMENT,
`document_version_name` VARCHAR(200) NOT NULL,
`document_version_description` TEXT DEFAULT NULL,
`document_version_content` LONGTEXT NOT NULL,
`document_version_created_by` INT(11) DEFAULT 0,
`document_version_created_at` DATETIME NOT NULL,
`document_version_document_id` INT(11) NOT NULL,
PRIMARY KEY (`document_version_id`)
)");
// Delete all Current Document Versions
mysqli_query($mysqli, "
DELETE FROM `documents`
WHERE `document_parent` > 0 AND `document_parent` != `document_id`
");
mysqli_query($mysqli, "ALTER TABLE `documents` DROP `document_parent`");

View File

@@ -0,0 +1,52 @@
<?php
/*
* ITFlow - Database update to version 2.1.7 (from 2.1.6)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "CREATE TABLE `document_templates` (
`document_template_id` INT(11) NOT NULL AUTO_INCREMENT,
`document_template_name` VARCHAR(200) NOT NULL,
`document_template_description` TEXT DEFAULT NULL,
`document_template_content` LONGTEXT NOT NULL,
`document_template_created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`document_template_updated_at` DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
`document_template_archived_at` DATETIME NULL DEFAULT NULL,
`document_template_created_by` INT(11) NOT NULL DEFAULT 0,
`document_template_updated_by` INT(11) NOT NULL DEFAULT 0,
PRIMARY KEY (`document_template_id`)
)");
// Copy Document Templates over to new document templates table
mysqli_query($mysqli, "
INSERT INTO document_templates (
document_template_name,
document_template_description,
document_template_content,
document_template_created_at,
document_template_updated_at,
document_template_archived_at,
document_template_created_by,
document_template_updated_by
)
SELECT
document_name,
document_description,
document_content,
document_created_at,
document_updated_at,
document_archived_at,
document_created_by,
document_updated_by
FROM
documents
WHERE
document_template = 1
");
mysqli_query($mysqli, "DELETE FROM documents WHERE document_template = 1");
mysqli_query($mysqli, "ALTER TABLE `documents` DROP `document_template`");

View File

@@ -0,0 +1,57 @@
<?php
/*
* ITFlow - Database update to version 2.1.8 (from 2.1.7)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "CREATE TABLE `software_templates` (
`software_template_id` INT(11) NOT NULL AUTO_INCREMENT,
`software_template_name` VARCHAR(200) NOT NULL,
`software_template_description` TEXT DEFAULT NULL,
`software_template_version` VARCHAR(200) DEFAULT NULL,
`software_template_type` VARCHAR(200) NOT NULL,
`software_template_license_type` VARCHAR(200) DEFAULT NULL,
`software_template_notes` TEXT DEFAULT NULL,
`software_template_created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`software_template_updated_at` DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
`software_template_archived_at` DATETIME NULL DEFAULT NULL,
PRIMARY KEY (`software_template_id`)
)");
// Copy software Templates over to new software templates table
mysqli_query($mysqli, "
INSERT INTO software_templates (
software_template_name,
software_template_description,
software_template_version,
software_template_type,
software_template_license_type,
software_template_notes,
software_template_created_at,
software_template_updated_at,
software_template_archived_at
)
SELECT
software_name,
software_description,
software_version,
software_type,
software_license_type,
software_notes,
software_created_at,
software_updated_at,
software_archived_at
FROM
software
WHERE
software_template = 1
");
mysqli_query($mysqli, "DELETE FROM software WHERE software_template = 1");
mysqli_query($mysqli, "ALTER TABLE `software` DROP `software_template`");
mysqli_query($mysqli, "ALTER TABLE `software` DROP `software_template_id`");

View File

@@ -0,0 +1,76 @@
<?php
/*
* ITFlow - Database update to version 2.1.9 (from 2.1.8)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "CREATE TABLE `vendor_templates` (
`vendor_template_id` INT(11) NOT NULL AUTO_INCREMENT,
`vendor_template_name` VARCHAR(200) NOT NULL,
`vendor_template_description` VARCHAR(200) DEFAULT NULL,
`vendor_template_contact_name` VARCHAR(200) DEFAULT NULL,
`vendor_template_phone_country_code` VARCHAR(10) DEFAULT NULL,
`vendor_template_phone` VARCHAR(200) DEFAULT NULL,
`vendor_template_extension` VARCHAR(200) DEFAULT NULL,
`vendor_template_email` VARCHAR(200) DEFAULT NULL,
`vendor_template_website` VARCHAR(200) DEFAULT NULL,
`vendor_template_hours` VARCHAR(200) DEFAULT NULL,
`vendor_template_sla` VARCHAR(200) DEFAULT NULL,
`vendor_template_code` VARCHAR(200) DEFAULT NULL,
`vendor_template_account_number` VARCHAR(200) DEFAULT NULL,
`vendor_template_notes` TEXT DEFAULT NULL,
`vendor_template_created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`vendor_template_updated_at` DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
`vendor_template_archived_at` DATETIME NULL DEFAULT NULL,
PRIMARY KEY (`vendor_template_id`)
)");
// Copy Vendor Templates over to new vendor templates table
mysqli_query($mysqli, "
INSERT INTO vendor_templates (
vendor_template_name,
vendor_template_description,
vendor_template_contact_name,
vendor_template_phone_country_code,
vendor_template_phone,
vendor_template_extension,
vendor_template_email,
vendor_template_website,
vendor_template_hours,
vendor_template_sla,
vendor_template_code,
vendor_template_account_number,
vendor_template_notes,
vendor_template_created_at,
vendor_template_updated_at,
vendor_template_archived_at
)
SELECT
vendor_name,
vendor_description,
vendor_contact_name,
vendor_phone_country_code,
vendor_phone,
vendor_extension,
vendor_email,
vendor_website,
vendor_hours,
vendor_sla,
vendor_code,
vendor_account_number,
vendor_notes,
vendor_created_at,
vendor_updated_at,
vendor_archived_at
FROM
vendors
WHERE
vendor_template = 1
");
mysqli_query($mysqli, "DELETE FROM vendors WHERE vendor_template = 1");
mysqli_query($mysqli, "ALTER TABLE `vendors` DROP `vendor_template`");

View File

@@ -0,0 +1,10 @@
<?php
/*
* ITFlow - Database update to version 2.2.0 (from 2.1.9)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "ALTER TABLE `companies` MODIFY `company_currency` VARCHAR(200) DEFAULT 'USD'");

View File

@@ -0,0 +1,10 @@
<?php
/*
* ITFlow - Database update to version 2.2.1 (from 2.2.0)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "ALTER TABLE `tickets` ADD `ticket_quote_id` INT(11) NOT NULL DEFAULT 0 AFTER `ticket_asset_id`");

View File

@@ -0,0 +1,34 @@
<?php
/*
* ITFlow - Database update to version 2.2.2 (from 2.2.1)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "CREATE TABLE `ai_providers` (
`ai_provider_id` INT(11) NOT NULL AUTO_INCREMENT,
`ai_provider_name` VARCHAR(200) NOT NULL,
`ai_provider_api_url` VARCHAR(200) NOT NULL,
`ai_provider_api_key` VARCHAR(200) DEFAULT NULL,
`ai_provider_created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`ai_provider_updated_at` DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`ai_provider_id`)
)");
mysqli_query($mysqli, "
CREATE TABLE `ai_models` (
`ai_model_id` INT(11) NOT NULL AUTO_INCREMENT,
`ai_model_name` VARCHAR(200) NOT NULL,
`ai_model_prompt` TEXT DEFAULT NULL,
`ai_model_use_case` VARCHAR(200) DEFAULT NULL,
`ai_model_created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`ai_model_updated_at` DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
`ai_model_ai_provider_id` INT(11) NOT NULL,
PRIMARY KEY (`ai_model_id`),
FOREIGN KEY (`ai_model_ai_provider_id`)
REFERENCES `ai_providers`(`ai_provider_id`)
ON DELETE CASCADE
)
");

View File

@@ -0,0 +1,62 @@
<?php
/*
* ITFlow - Database update to version 2.2.3 (from 2.2.2)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "CREATE TABLE `payment_methods` (
`payment_method_id` INT(11) NOT NULL AUTO_INCREMENT,
`payment_method_name` VARCHAR(200) NOT NULL,
`payment_method_description` VARCHAR(250) DEFAULT NULL,
`payment_method_created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`payment_method_updated_at` DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`payment_method_id`)
)");
mysqli_query($mysqli, "CREATE TABLE `payment_providers` (
`payment_provider_id` INT(11) NOT NULL AUTO_INCREMENT,
`payment_provider_name` VARCHAR(200) NOT NULL,
`payment_provider_description` VARCHAR(250) DEFAULT NULL,
`payment_provider_public_key` VARCHAR(250) DEFAULT NULL,
`payment_provider_private_key` VARCHAR(250) DEFAULT NULL,
`payment_provider_threshold` DECIMAL(15,2) DEFAULT NULL,
`payment_provider_active` TINYINT(1) NOT NULL DEFAULT 1,
`payment_provider_account` INT(11) NOT NULL,
`payment_provider_expense_vendor` INT(11) NOT NULL DEFAULT 0,
`payment_provider_expense_category` INT(11) NOT NULL DEFAULT 0,
`payment_provider_expense_percentage_fee` DECIMAL(4,4) DEFAULT NULL,
`payment_provider_expense_flat_fee` DECIMAL(15,2) DEFAULT NULL,
`payment_provider_created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`payment_provider_updated_at` DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`payment_provider_id`)
)");
mysqli_query($mysqli, "CREATE TABLE `client_saved_payment_methods` (
`saved_payment_id` INT(11) NOT NULL AUTO_INCREMENT,
`saved_payment_provider_method` VARCHAR(200) NOT NULL,
`saved_payment_description` VARCHAR(200) DEFAULT NULL,
`saved_payment_client_id` INT(11) NOT NULL,
`saved_payment_provider_id` INT(11) NOT NULL,
`saved_payment_created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`saved_payment_updated_at` DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`saved_payment_id`),
FOREIGN KEY (`saved_payment_client_id`) REFERENCES clients(`client_id`) ON DELETE CASCADE,
FOREIGN KEY (`saved_payment_provider_id`) REFERENCES payment_providers(`payment_provider_id`) ON DELETE CASCADE
)");
mysqli_query($mysqli, "CREATE TABLE `client_payment_provider` (
`client_id` INT(11) NOT NULL,
`payment_provider_id` INT(11) NOT NULL,
`payment_provider_client` VARCHAR(200) NOT NULL,
`client_payment_provider_created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`client_id`, `payment_provider_id`),
FOREIGN KEY (`client_id`) REFERENCES clients(`client_id`) ON DELETE CASCADE,
FOREIGN KEY (`payment_provider_id`) REFERENCES payment_providers(`payment_provider_id`) ON DELETE CASCADE
)");
mysqli_query($mysqli, "ALTER TABLE `recurring_payments` ADD `recurring_payment_saved_payment_id` INT(11) DEFAULT NULL AFTER `recurring_payment_recurring_invoice_id`");
mysqli_query($mysqli, "ALTER TABLE `recurring_payments` ADD CONSTRAINT `fk_recurring_saved_payment` FOREIGN KEY (`recurring_payment_saved_payment_id`) REFERENCES `client_saved_payment_methods`(`saved_payment_id`) ON DELETE CASCADE");

View File

@@ -0,0 +1,34 @@
<?php
/*
* ITFlow - Database update to version 2.2.4 (from 2.2.3)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "CREATE TABLE `credits` (
`credit_id` INT(11) NOT NULL AUTO_INCREMENT,
`credit_amount` DECIMAL(15,2) NOT NULL,
`credit_reference` VARCHAR(250) DEFAULT NULL,
`credit_created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP(),
`credit_created_by` INT(11) NOT NULL,
`credit_expire_at` DATE DEFAULT NULL,
`credit_client_id` INT(11) NOT NULL,
PRIMARY KEY (`credit_id`)
)");
mysqli_query($mysqli, "ALTER TABLE `invoices` ADD `invoice_credit_amount` DECIMAL(15,2) NOT NULL DEFAULT 0.00 AFTER `invoice_discount_amount`");
mysqli_query($mysqli, "CREATE TABLE `discount_codes` (
`discount_code_id` INT(11) NOT NULL AUTO_INCREMENT,
`discount_code_description` VARCHAR(250) DEFAULT NULL,
`discount_code_amount` DECIMAL(15,2) NOT NULL,
`discount_code` VARCHAR(200) NOT NULL,
`discount_code_created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP(),
`discount_code_created_by` INT(11) NOT NULL,
`discount_code_updated_at` DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
`discount_code_archived_at` DATETIME NULL DEFAULT NULL,
`discount_code_expire_at` DATE DEFAULT NULL,
PRIMARY KEY (`discount_code_id`)
)");

View File

@@ -0,0 +1,10 @@
<?php
/*
* ITFlow - Database update to version 2.2.5 (from 2.2.4)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "ALTER TABLE `settings` ADD `config_theme_dark` TINYINT(1) NOT NULL DEFAULT 0 AFTER `config_theme`");

View File

@@ -0,0 +1,10 @@
<?php
/*
* ITFlow - Database update to version 2.2.6 (from 2.2.5)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "ALTER TABLE `assets` ADD `asset_uri_client` VARCHAR(500) NULL DEFAULT NULL AFTER `asset_uri_2`");

View File

@@ -0,0 +1,16 @@
<?php
/*
* ITFlow - Database update to version 2.2.7 (from 2.2.6)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "ALTER TABLE `credits` DROP `credit_reference`");
mysqli_query($mysqli, "ALTER TABLE `credits` ADD `credit_type` ENUM('prepaid', 'manual', 'refund', 'promotion', 'usage') NOT NULL DEFAULT 'manual' AFTER `credit_amount`");
mysqli_query($mysqli, "ALTER TABLE `credits` ADD `credit_note` TEXT NULL DEFAULT NULL AFTER `credit_type`");
mysqli_query($mysqli, "ALTER TABLE `credits` ADD `credit_invoice_id` INT(11) NULL DEFAULT NULL AFTER `credit_expire_at`");
mysqli_query($mysqli, "ALTER TABLE `credits` ADD INDEX (`credit_client_id`)");
mysqli_query($mysqli, "ALTER TABLE `credits` ADD INDEX (`credit_invoice_id`)");
mysqli_query($mysqli, "ALTER TABLE `credits` ADD INDEX (`credit_created_at`)");

View File

@@ -0,0 +1,11 @@
<?php
/*
* ITFlow - Database update to version 2.2.8 (from 2.2.7)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "ALTER TABLE `user_settings` ADD `user_config_theme_dark` TINYINT(1) NOT NULL DEFAULT 0 AFTER `user_config_signature`");
mysqli_query($mysqli, "ALTER TABLE `settings` DROP `config_theme_dark`");

View File

@@ -0,0 +1,23 @@
<?php
/*
* ITFlow - Database update to version 2.2.9 (from 2.2.8)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "ALTER TABLE `products` ADD `product_type` ENUM('service', 'product') NOT NULL DEFAULT 'service' AFTER `product_name`");
mysqli_query($mysqli, "ALTER TABLE `products` ADD `product_code` VARCHAR(200) DEFAULT NULL AFTER `product_description`");
mysqli_query($mysqli, "ALTER TABLE `products` ADD `product_location` VARCHAR(250) DEFAULT NULL AFTER `product_code`");
mysqli_query($mysqli, "CREATE TABLE `product_stock` (
`stock_id` INT(11) NOT NULL AUTO_INCREMENT,
`stock_qty` INT(11) NOT NULL,
`stock_note` TEXT DEFAULT NULL,
`stock_created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP(),
`stock_expense_id` INT(11) DEFAULT NULL,
`stock_item_id` INT(11) DEFAULT NULL,
`stock_product_id` INT(11) NOT NULL,
PRIMARY KEY (`stock_id`)
)");

View File

@@ -0,0 +1,84 @@
<?php
/*
* ITFlow - Database update to version 2.3.0 (from 2.2.9)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Migrate Stripe Settings over to new Tables
// Get Current Stripe Settings
$sql_stripe_settings = mysqli_query($mysqli, "SELECT * FROM settings WHERE company_id = 1");
$row = mysqli_fetch_assoc($sql_stripe_settings);
$config_stripe_enable = intval($row['config_stripe_enable']);
if ($config_stripe_enable === 1) {
$config_stripe_publishable = mysqli_real_escape_string($mysqli, $row['config_stripe_publishable']);
$config_stripe_secret = mysqli_real_escape_string($mysqli, $row['config_stripe_secret']);
$config_stripe_account = intval($row['config_stripe_account']);
$config_stripe_expense_vendor = intval($row['config_stripe_expense_vendor']);
$config_stripe_expense_category = intval($row['config_stripe_expense_category']);
$config_stripe_percentage_fee = floatval($row['config_stripe_percentage_fee']);
$config_stripe_flat_fee = floatval($row['config_stripe_flat_fee']);
mysqli_query($mysqli,"INSERT INTO payment_providers SET
payment_provider_name = 'Stripe',
payment_provider_public_key = '$config_stripe_publishable',
payment_provider_private_key = '$config_stripe_secret',
payment_provider_account = $config_stripe_account,
payment_provider_expense_vendor = $config_stripe_expense_vendor,
payment_provider_expense_category = $config_stripe_expense_category,
payment_provider_expense_percentage_fee = $config_stripe_percentage_fee,
payment_provider_expense_flat_fee = $config_stripe_flat_fee"
);
$provider_id = mysqli_insert_id($mysqli);
// Migrate Clients and Payment Method over
$sql_stripe_clients = mysqli_query($mysqli, "SELECT * FROM client_stripe WHERE stripe_pm IS NOT NULL AND stripe_pm != ''");
while ($row = mysqli_fetch_assoc($sql_stripe_clients)) {
$client_id = intval($row['client_id']);
$stripe_id = mysqli_real_escape_string($mysqli, $row['stripe_id']);
$stripe_pm = mysqli_real_escape_string($mysqli, $row['stripe_pm']);
$stripe_pm_details = mysqli_real_escape_string($mysqli, $row['stripe_pm_details'] ?? 'Saved Card');
mysqli_query($mysqli,"INSERT INTO client_payment_provider SET
client_id = $client_id,
payment_provider_id = $provider_id,
payment_provider_client = '$stripe_id'"
);
mysqli_query($mysqli,"INSERT INTO client_saved_payment_methods SET
saved_payment_provider_method = '$stripe_pm',
saved_payment_description = '$stripe_pm_details',
saved_payment_client_id = $client_id,
saved_payment_provider_id = $provider_id"
);
}
}
// Get Stripe provider id
$res = mysqli_query($mysqli, "
SELECT payment_provider_id
FROM payment_providers
WHERE payment_provider_name = 'Stripe'
ORDER BY payment_provider_id DESC
LIMIT 1
");
$stripe = mysqli_fetch_assoc($res);
$stripe_provider_id = intval($stripe['payment_provider_id']);
// Correct mapping: RP -> Recurring Invoice -> Client -> Client's Stripe saved method
mysqli_query($mysqli, "
UPDATE recurring_payments rp
INNER JOIN recurring_invoices ri
ON ri.recurring_invoice_id = rp.recurring_payment_recurring_invoice_id
INNER JOIN client_saved_payment_methods spm
ON spm.saved_payment_client_id = ri.recurring_invoice_client_id
AND spm.saved_payment_provider_id = $stripe_provider_id
SET
rp.recurring_payment_method = 'Credit Card',
rp.recurring_payment_saved_payment_id = spm.saved_payment_id
WHERE rp.recurring_payment_method = 'Stripe'
");

View File

@@ -0,0 +1,17 @@
<?php
/*
* ITFlow - Database update to version 2.3.1 (from 2.3.0)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Migrate Payment Methods from Categories Table to new payment_methods table
$sql_categories = mysqli_query($mysqli, "SELECT category_name FROM categories WHERE category_type = 'Payment Method' AND category_name != 'Stripe' AND category_archived_at IS NULL");
while ($row = mysqli_fetch_assoc($sql_categories)) {
$category_name = escapeSql($row['category_name']);
mysqli_query($mysqli,"INSERT INTO payment_methods SET payment_method_name = '$category_name'");
}

View File

@@ -0,0 +1,31 @@
<?php
/*
* ITFlow - Database update to version 2.3.2 (from 2.3.1)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Delete all Recurring Payments that are Stripe
mysqli_query($mysqli, "DELETE FROM recurring_payments WHERE recurring_payment_method = 'Stripe'");
// Delete Stripe Specific ITFlow Client Stripe Client Relationship Table
mysqli_query($mysqli, "DROP TABLE client_stripe");
// Delete Unused Stripe and AI Settings now in their own tables
mysqli_query($mysqli, "ALTER TABLE `settings`
DROP `config_stripe_enable`,
DROP `config_stripe_publishable`,
DROP `config_stripe_secret`,
DROP `config_stripe_account`,
DROP `config_stripe_expense_vendor`,
DROP `config_stripe_expense_category`,
DROP `config_stripe_percentage_fee`,
DROP `config_stripe_flat_fee`,
DROP `config_ai_enable`,
DROP `config_ai_provider`,
DROP `config_ai_model`,
DROP `config_ai_url`,
DROP `config_ai_api_key`
");

View File

@@ -0,0 +1,18 @@
<?php
/*
* ITFlow - Database update to version 2.3.3 (from 2.3.2)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "ALTER TABLE settings
ADD `config_imap_provider` ENUM('standard_imap','google_oauth','microsoft_oauth') NULL DEFAULT NULL AFTER `config_mail_from_name`,
ADD `config_mail_oauth_client_id` VARCHAR(255) NULL AFTER `config_imap_provider`,
ADD `config_mail_oauth_client_secret` VARCHAR(255) NULL AFTER `config_mail_oauth_client_id`,
ADD `config_mail_oauth_tenant_id` VARCHAR(255) NULL AFTER `config_mail_oauth_client_secret`,
ADD `config_mail_oauth_refresh_token` TEXT NULL AFTER `config_mail_oauth_tenant_id`,
ADD `config_mail_oauth_access_token` TEXT NULL AFTER `config_mail_oauth_refresh_token`,
ADD `config_mail_oauth_access_token_expires_at` DATETIME NULL AFTER `config_mail_oauth_access_token`
");

View File

@@ -0,0 +1,12 @@
<?php
/*
* ITFlow - Database update to version 2.3.4 (from 2.3.3)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "ALTER TABLE settings
ADD `config_smtp_provider` ENUM('standard_smtp','google_oauth','microsoft_oauth') NULL DEFAULT NULL AFTER `config_start_page`
");

View File

@@ -0,0 +1,37 @@
<?php
/*
* ITFlow - Database update to version 2.3.5 (from 2.3.4)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Add Software Keys
mysqli_query($mysqli, "CREATE TABLE `software_keys` (
`software_key_id` INT(11) NOT NULL AUTO_INCREMENT,
`software_key` VARCHAR(400) NOT NULL,
`software_key_software_id` INT(11) NOT NULL,
PRIMARY KEY (`software_key_id`),
FOREIGN KEY (`software_key_software_id`) REFERENCES `software`(`software_id`) ON DELETE CASCADE
)");
// Software Key Assignments to Contacts
mysqli_query($mysqli, "CREATE TABLE `software_key_contact_assignments` (
`software_key_id` INT(11) NOT NULL,
`contact_id` INT(11) NOT NULL,
`software_key_assigned_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`software_key_id`, `contact_id`),
FOREIGN KEY (`software_key_id`) REFERENCES `software_keys`(`software_key_id`) ON DELETE CASCADE,
FOREIGN KEY (`contact_id`) REFERENCES `contacts`(`contact_id`) ON DELETE CASCADE
)");
// Software Key Assignments to Assets
mysqli_query($mysqli, "CREATE TABLE `software_key_asset_assignments` (
`software_key_id` INT(11) NOT NULL,
`asset_id` INT(11) NOT NULL,
`software_key_assigned_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`software_key_id`, `asset_id`),
FOREIGN KEY (`software_key_id`) REFERENCES `software_keys`(`software_key_id`) ON DELETE CASCADE,
FOREIGN KEY (`asset_id`) REFERENCES `assets`(`asset_id`) ON DELETE CASCADE
)");

View File

@@ -0,0 +1,11 @@
<?php
/*
* ITFlow - Database update to version 2.3.6 (from 2.3.5)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "ALTER TABLE `settings` CHANGE `config_smtp_provider` `config_smtp_provider` VARCHAR(200) DEFAULT NULL");
mysqli_query($mysqli, "ALTER TABLE `settings` CHANGE `config_imap_provider` `config_imap_provider` VARCHAR(200) DEFAULT NULL");

View File

@@ -0,0 +1,84 @@
<?php
/*
* ITFlow - Database update to version 2.3.7 (from 2.3.6)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Create New Contract Templates Table
mysqli_query($mysqli, "CREATE TABLE `contract_templates` (
`contract_template_id` INT(11) AUTO_INCREMENT PRIMARY KEY,
`contract_template_name` VARCHAR(255) NOT NULL,
`contract_template_description` TEXT NULL DEFAULT NULL,
`contract_template_type` VARCHAR(50) NULL DEFAULT NULL,
`contract_template_sla_low_response_time` INT(11) NULL DEFAULT NULL,
`contract_template_sla_low_resolution_time` INT(11) NULL DEFAULT NULL,
`contract_template_sla_medium_response_time` INT(11) NULL DEFAULT NULL,
`contract_template_sla_medium_resolution_time` INT(11) NULL DEFAULT NULL,
`contract_template_sla_high_response_time` INT(11) NULL DEFAULT NULL,
`contract_template_sla_high_resolution_time` INT(11) NULL DEFAULT NULL,
`contract_template_rate_standard` DECIMAL(10,2) NULL DEFAULT NULL,
`contract_template_rate_after_hours` DECIMAL(10,2) NULL DEFAULT NULL,
`contract_template_net_terms` VARCHAR(50) NULL DEFAULT NULL,
`contract_template_support_hours` VARCHAR(100) NULL DEFAULT NULL,
`contract_template_renewal_frequency` VARCHAR(50) NULL DEFAULT NULL,
`contract_template_details` TEXT NULL DEFAULT NULL,
`contract_template_created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`contract_template_updated_at` DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
`contract_template_archived_at` DATETIME NULL DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
// Create New Contracts Table
mysqli_query($mysqli, "CREATE TABLE `contracts` (
`contract_id` INT(11) AUTO_INCREMENT PRIMARY KEY,
`contract_name` VARCHAR(255) NOT NULL,
`contract_status` VARCHAR(50) NOT NULL,
`contract_type` VARCHAR(50) NOT NULL,
`contract_sla_low_response_time` INT(11) NULL DEFAULT NULL,
`contract_sla_low_resolution_time` INT(11) NULL DEFAULT NULL,
`contract_sla_medium_response_time` INT(11) NULL DEFAULT NULL,
`contract_sla_medium_resolution_time` INT(11) NULL DEFAULT NULL,
`contract_sla_high_response_time` INT(11) NULL DEFAULT NULL,
`contract_sla_high_resolution_time` INT(11) NULL DEFAULT NULL,
`contract_details` TEXT NULL DEFAULT NULL,
`contract_client_id` INT(11) NULL DEFAULT NULL,
`contract_client_name` VARCHAR(255) NULL DEFAULT NULL,
`contract_client_address` TEXT NULL DEFAULT NULL,
`contract_client_email` VARCHAR(255) NULL DEFAULT NULL,
`contract_client_phone` VARCHAR(100) NULL DEFAULT NULL,
`contract_contact_name` VARCHAR(255) NULL DEFAULT NULL,
`contract_contact_signature` TEXT NULL DEFAULT NULL,
`contract_contact_signature_date` DATETIME NULL DEFAULT NULL,
`contract_agent_name` VARCHAR(255) NULL DEFAULT NULL,
`contract_agent_signature` TEXT NULL DEFAULT NULL,
`contract_agent_signature_date` DATETIME NULL DEFAULT NULL,
`contract_rate_standard` DECIMAL(10,2) NULL DEFAULT NULL,
`contract_rate_after_hours` DECIMAL(10,2) NULL DEFAULT NULL,
`contract_net_terms` VARCHAR(50) NULL DEFAULT NULL,
`contract_support_hours` VARCHAR(100) NULL DEFAULT NULL,
`contract_start_date` DATE NULL DEFAULT NULL,
`contract_end_date` DATE NULL DEFAULT NULL,
`contract_renewal_frequency` VARCHAR(50) NULL DEFAULT NULL,
`contract_created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`contract_updated_at` DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
`contract_archived_at` DATETIME NULL DEFAULT NULL,
FOREIGN KEY (`contract_client_id`) REFERENCES `clients`(`client_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");

View File

@@ -0,0 +1,24 @@
<?php
/*
* ITFlow - Database update to version 2.3.8 (from 2.3.7)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "
CREATE TABLE `asset_tags` (
`asset_tag_asset_id` INT(11) NOT NULL,
`asset_tag_tag_id` INT(11) NOT NULL,
PRIMARY KEY (`asset_tag_asset_id`, `asset_tag_tag_id`),
CONSTRAINT `fk_asset`
FOREIGN KEY (`asset_tag_asset_id`)
REFERENCES `assets`(`asset_id`)
ON DELETE CASCADE,
CONSTRAINT `fk_tag`
FOREIGN KEY (`asset_tag_tag_id`)
REFERENCES `tags`(`tag_id`)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");

View File

@@ -0,0 +1,23 @@
<?php
/*
* ITFlow - Database update to version 2.3.9 (from 2.3.8)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "
CREATE TABLE `task_approvals` (
`approval_id` int(11) NOT NULL AUTO_INCREMENT,
`approval_scope` enum('client','internal') NOT NULL,
`approval_type` enum('any','technical','billing','specific') NOT NULL,
`approval_required_user_id` int(11) DEFAULT NULL,
`approval_status` enum('pending','approved','declined') NOT NULL,
`approval_created_by` int(11) NOT NULL,
`approval_approved_by` varchar(255) DEFAULT NULL,
`approval_url_key` varchar(200) NOT NULL,
`approval_task_id` int(11) NOT NULL,
PRIMARY KEY (`approval_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");

View File

@@ -0,0 +1,43 @@
<?php
/*
* ITFlow - Database update to version 2.4.0 (from 2.3.9)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "ALTER TABLE `clients` ADD `client_favorite` TINYINT(1) NOT NULL DEFAULT '0' AFTER `client_notes`");
mysqli_query($mysqli, "ALTER TABLE `locations` ADD `location_favorite` TINYINT(1) NOT NULL DEFAULT '0' AFTER `location_notes`");
mysqli_query($mysqli, "ALTER TABLE `vendors` ADD `vendor_favorite` TINYINT(1) NOT NULL DEFAULT '0' AFTER `vendor_notes`");
mysqli_query($mysqli, "ALTER TABLE `software` ADD `software_favorite` TINYINT(1) NOT NULL DEFAULT '0' AFTER `software_notes`");
mysqli_query(
$mysqli,
"ALTER TABLE `credentials`
CHANGE `credential_important` `credential_favorite`
TINYINT(1) NOT NULL DEFAULT 0
AFTER `credential_note`"
);
mysqli_query($mysqli, "ALTER TABLE `assets` DROP `asset_important`");
mysqli_query($mysqli, "ALTER TABLE `assets` ADD `asset_favorite` TINYINT(1) NOT NULL DEFAULT '0' AFTER `asset_notes`");
mysqli_query($mysqli, "ALTER TABLE `documents` DROP `document_important`");
mysqli_query($mysqli, "ALTER TABLE `documents` ADD `document_favorite` TINYINT(1) NOT NULL DEFAULT '0' AFTER `document_client_visible`");
mysqli_query($mysqli, "ALTER TABLE `racks` ADD `rack_favorite` TINYINT(1) NOT NULL DEFAULT '0' AFTER `rack_notes`");
mysqli_query($mysqli, "ALTER TABLE `files` DROP `file_important`");
mysqli_query($mysqli, "ALTER TABLE `files` ADD `file_favorite` TINYINT(1) NOT NULL DEFAULT '0' AFTER `file_mime_type`");
mysqli_query($mysqli, "ALTER TABLE `networks` ADD `network_favorite` TINYINT(1) NOT NULL DEFAULT '0' AFTER `network_notes`");
mysqli_query($mysqli, "ALTER TABLE `domains` ADD `domain_favorite` TINYINT(1) NOT NULL DEFAULT '0' AFTER `domain_notes`");
mysqli_query($mysqli, "ALTER TABLE `certificates` ADD `certificate_favorite` TINYINT(1) NOT NULL DEFAULT '0' AFTER `certificate_notes`");
mysqli_query($mysqli, "ALTER TABLE `services` ADD `service_favorite` TINYINT(1) NOT NULL DEFAULT '0' AFTER `service_notes`");

View File

@@ -0,0 +1,50 @@
<?php
/*
* ITFlow - Database update to version 2.4.1 (from 2.4.0)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "
CREATE TABLE `quote_items` (
`item_id` int(11) NOT NULL AUTO_INCREMENT,
`item_name` varchar(200) NOT NULL,
`item_description` text DEFAULT NULL,
`item_quantity` decimal(15,2) NOT NULL DEFAULT 0.00,
`item_price` decimal(15,2) NOT NULL DEFAULT 0.00,
`item_subtotal` decimal(15,2) NOT NULL DEFAULT 0.00,
`item_tax` decimal(15,2) NOT NULL DEFAULT 0.00,
`item_total` decimal(15,2) NOT NULL DEFAULT 0.00,
`item_order` int(11) NOT NULL DEFAULT 0,
`item_created_at` datetime NOT NULL DEFAULT current_timestamp(),
`item_updated_at` datetime DEFAULT NULL ON UPDATE current_timestamp(),
`item_archived_at` datetime DEFAULT NULL,
`item_tax_id` int(11) NOT NULL DEFAULT 0,
`item_product_id` int(11) NOT NULL DEFAULT 0,
`item_quote_id` int(11) NOT NULL,
PRIMARY KEY (`item_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");
mysqli_query($mysqli, "
CREATE TABLE `recurring_invoice_items` (
`item_id` int(11) NOT NULL AUTO_INCREMENT,
`item_name` varchar(200) NOT NULL,
`item_description` text DEFAULT NULL,
`item_quantity` decimal(15,2) NOT NULL DEFAULT 0.00,
`item_price` decimal(15,2) NOT NULL DEFAULT 0.00,
`item_subtotal` decimal(15,2) NOT NULL DEFAULT 0.00,
`item_tax` decimal(15,2) NOT NULL DEFAULT 0.00,
`item_total` decimal(15,2) NOT NULL DEFAULT 0.00,
`item_order` int(11) NOT NULL DEFAULT 0,
`item_created_at` datetime NOT NULL DEFAULT current_timestamp(),
`item_updated_at` datetime DEFAULT NULL ON UPDATE current_timestamp(),
`item_archived_at` datetime DEFAULT NULL,
`item_tax_id` int(11) NOT NULL DEFAULT 0,
`item_product_id` int(11) NOT NULL DEFAULT 0,
`item_recurring_invoice_id` int(11) NOT NULL,
PRIMARY KEY (`item_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
");

View File

@@ -0,0 +1,97 @@
<?php
/*
* ITFlow - Database update to version 2.4.2 (from 2.4.1)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Migrate Items
mysqli_query($mysqli, "
INSERT INTO `recurring_invoice_items` (
`item_name`,
`item_description`,
`item_quantity`,
`item_price`,
`item_subtotal`,
`item_tax`,
`item_total`,
`item_order`,
`item_created_at`,
`item_updated_at`,
`item_archived_at`,
`item_tax_id`,
`item_product_id`,
`item_recurring_invoice_id`
)
SELECT
`item_name`,
`item_description`,
`item_quantity`,
`item_price`,
`item_subtotal`,
`item_tax`,
`item_total`,
`item_order`,
`item_created_at`,
`item_updated_at`,
`item_archived_at`,
`item_tax_id`,
`item_product_id`,
`item_recurring_invoice_id`
FROM `invoice_items`
WHERE `item_recurring_invoice_id` != 0
");
mysqli_query($mysqli, "
INSERT INTO `quote_items` (
`item_name`,
`item_description`,
`item_quantity`,
`item_price`,
`item_subtotal`,
`item_tax`,
`item_total`,
`item_order`,
`item_created_at`,
`item_updated_at`,
`item_archived_at`,
`item_tax_id`,
`item_product_id`,
`item_quote_id`
)
SELECT
`item_name`,
`item_description`,
`item_quantity`,
`item_price`,
`item_subtotal`,
`item_tax`,
`item_total`,
`item_order`,
`item_created_at`,
`item_updated_at`,
`item_archived_at`,
`item_tax_id`,
`item_product_id`,
`item_quote_id`
FROM `invoice_items`
WHERE `item_quote_id` != 0
");
mysqli_query($mysqli, "
DELETE FROM `invoice_items`
WHERE `item_recurring_invoice_id` != 0
");
mysqli_query($mysqli, "
DELETE FROM `invoice_items`
WHERE `item_quote_id` != 0
");
mysqli_query($mysqli, "
ALTER TABLE `invoice_items`
DROP COLUMN `item_quote_id`,
DROP COLUMN `item_recurring_invoice_id`
");

View File

@@ -0,0 +1,21 @@
<?php
/*
* ITFlow - Database update to version 2.4.3 (from 2.4.2)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "ALTER TABLE `categories` ADD `category_description` VARCHAR(255) DEFAULT NULL AFTER `category_name`");
mysqli_query($mysqli, "ALTER TABLE `categories` ADD `category_order` INT(11) NOT NULL DEFAULT 0 AFTER `category_icon`");
// Create network_interfaces
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Ethernet', category_type = 'network_interface', category_order = 1"); // 1
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'SFP', category_type = 'network_interface', category_order = 2"); // 2
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'SFP+', category_type = 'network_interface', category_order = 3"); // 3
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'QSFP28', category_type = 'network_interface', category_order = 4"); // 4
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'QSFP-DD', category_type = 'network_interface', category_order = 5"); // 5
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Coaxial', category_type = 'network_interface', category_order = 6"); // 6
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Fiber', category_type = 'network_interface', category_order = 7"); // 7
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'WiFi', category_type = 'network_interface', category_order = 8"); // 8

View File

@@ -0,0 +1,42 @@
<?php
/*
* ITFlow - Database update to version 2.4.4 (from 2.4.3)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Asset Status
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Ready to Deploy', category_description = 'Asset is configured and ready to be assigned', category_type = 'asset_status', category_order = 1"); // 1
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Deployed', category_description = 'Asset is actively in use and assigned to a client or location', category_type = 'asset_status', category_order = 2"); // 2
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Out for Repair', category_description = 'Asset has been sent out for servicing or repair', category_type = 'asset_status', category_order = 3"); // 3
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Lost', category_description = 'Asset location is unknown and cannot be accounted for', category_type = 'asset_status', category_order = 4"); // 4
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Stolen', category_description = 'Asset has been reported stolen', category_type = 'asset_status', category_order = 5"); // 5
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Retired', category_description = 'Asset has been decommissioned and is no longer in service', category_type = 'asset_status', category_order = 6"); // 6
// Contact note types
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Call', category_description = 'Phone call with a client or contact', category_icon = 'fa-phone-alt', category_type = 'contact_note_type', category_order = 1"); // 1
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Email', category_description = 'Email correspondence with a client or contact', category_icon = 'fa-envelope', category_type = 'contact_note_type', category_order = 2"); // 2
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Meeting', category_description = 'Scheduled meeting with a client or contact', category_icon = 'fa-handshake', category_type = 'contact_note_type', category_order = 3"); // 3
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'In Person', category_description = 'In person visit or on-site interaction', category_icon = 'fa-people-arrows', category_type = 'contact_note_type', category_order = 4"); // 4
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Note', category_description = 'General note or internal comment', category_icon = 'fa-sticky-note', category_type = 'contact_note_type', category_order = 5"); // 5
// Rack Types
mysqli_query($mysqli, "INSERT INTO categories SET category_name = '2-Post Open Frame', category_description = 'Two-post open frame rack for patch panels and lightweight equipment', category_type = 'rack_type', category_order = 1"); // 1
mysqli_query($mysqli, "INSERT INTO categories SET category_name = '4-Post Open Frame', category_description = 'Four-post open frame rack for servers and heavier equipment', category_type = 'rack_type', category_order = 2"); // 2
mysqli_query($mysqli, "INSERT INTO categories SET category_name = '4-Post Enclosed Cabinet', category_description = 'Four-post enclosed cabinet with doors and sides for secure equipment housing', category_type = 'rack_type', category_order = 3"); // 3
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Wall-Mount Open', category_description = 'Open frame rack mounted directly to a wall for small deployments', category_type = 'rack_type', category_order = 4"); // 4
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Wall-Mount Enclosed', category_description = 'Enclosed cabinet rack mounted to a wall with a locking door', category_type = 'rack_type', category_order = 5"); // 5
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Other', category_description = 'Rack type does not fit any standard category', category_type = 'rack_type', category_order = 6"); // 6
// Software Types
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Software as a Service (SaaS)', category_description = 'Cloud-hosted software accessed via a web browser or API', category_type = 'software_type', category_order = 1"); // 1
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Productivity Suite', category_description = 'Bundled office and collaboration tools such as Microsoft 365 or Google Workspace', category_type = 'software_type', category_order = 2"); // 2
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Web Application', category_description = 'Application hosted on a web server and accessed through a browser', category_type = 'software_type', category_order = 3"); // 3
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Desktop Application', category_description = 'Application installed and run locally on a workstation or laptop', category_type = 'software_type', category_order = 4"); // 4
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Mobile Application', category_description = 'Application installed and run on a mobile device or tablet', category_type = 'software_type', category_order = 5"); // 5
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Security Software', category_description = 'Software providing antivirus, endpoint protection, or security monitoring', category_type = 'software_type', category_order = 6"); // 6
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'System Software', category_description = 'Low-level software managing hardware resources and system operations', category_type = 'software_type', category_order = 7"); // 7
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Operating System', category_description = 'Core software managing hardware and providing a platform for applications', category_type = 'software_type', category_order = 8"); // 8
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Other', category_description = 'Software type does not fit any standard category', category_type = 'software_type', category_order = 9"); // 9

View File

@@ -0,0 +1,11 @@
<?php
/*
* ITFlow - Database update to version 2.4.5 (from 2.4.4)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Gateway fee expense now uses the actual fee from Stripe's balance transaction
mysqli_query($mysqli, "ALTER TABLE `payment_providers` DROP `payment_provider_expense_percentage_fee`, DROP `payment_provider_expense_flat_fee`");

View File

@@ -0,0 +1,11 @@
<?php
/*
* ITFlow - Database update to version 2.4.6 (from 2.4.5)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Add Allow Deny Client Access to the Enforce Client Permissions
mysqli_query($mysqli, "ALTER TABLE `user_client_permissions` ADD COLUMN `permission_type` ENUM('allow','deny') NOT NULL DEFAULT 'allow'");

View File

@@ -0,0 +1,18 @@
<?php
/*
* ITFlow - Database update to version 2.4.7 (from 2.4.6)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// API keys now run as a user and inherit that user's RBAC (module / operation / client).
// Existing keys predate this and can't be safely mapped to a user, so they are removed
// and must be recreated with an owning user.
mysqli_query($mysqli, "DELETE FROM api_keys");
// Tie keys to a user; client access now derives from that user, so the old per-key
// client scope is removed.
mysqli_query($mysqli, "ALTER TABLE `api_keys` ADD COLUMN `api_key_user_id` INT(11) NOT NULL DEFAULT 0");
mysqli_query($mysqli, "ALTER TABLE `api_keys` DROP COLUMN `api_key_client_id`");

View File

@@ -0,0 +1,16 @@
<?php
/*
* ITFlow - Database update to version 2.4.8 (from 2.4.7)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// The login lockout queries run on every login POST and filter the logs table by
// a 10 minute window. With only PRIMARY KEY (log_id) that is a full scan of a
// table that grows with every action in the app, which an attacker can trigger
// once per request. Indexing log_created_at bounds both lockout queries to the
// recent rows, and also speeds up the nightly log purge in cron.php, which had
// the same problem.
mysqli_query($mysqli, "ALTER TABLE `logs` ADD INDEX `log_created_at` (`log_created_at`)");

View File

@@ -0,0 +1,59 @@
<?php
/*
* ITFlow - Database update to version 2.5.0 (from 2.4.8)
* Included by admin/database_updates.php - do not access directly
*
* (2.4.9 was briefly used by the reverted refunds change - skipping that
* number so instances that applied it before the revert still pick this up)
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Ticket SLAs. slas holds the response/resolution targets and
// sla_assignments maps a client + priority to an SLA (client 0 rows are
// the global default, an assignment pointing at SLA 0 is an explicit "no
// SLA" override). SLAs are optional - with no assignments defined, nothing
// in the app changes behaviour.
mysqli_query($mysqli, "CREATE TABLE `sla_assignments` (
`sla_assignment_id` int(11) NOT NULL AUTO_INCREMENT,
`sla_assignment_client_id` int(11) NOT NULL DEFAULT 0,
`sla_assignment_priority` varchar(200) NOT NULL,
`sla_assignment_sla_id` int(11) NOT NULL DEFAULT 0,
PRIMARY KEY (`sla_assignment_id`),
UNIQUE KEY `sla_assignment_client_priority` (`sla_assignment_client_id`,`sla_assignment_priority`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci");
mysqli_query($mysqli, "CREATE TABLE `slas` (
`sla_id` int(11) NOT NULL AUTO_INCREMENT,
`sla_name` varchar(200) NOT NULL,
`sla_description` varchar(500) DEFAULT NULL,
`sla_response_minutes` int(11) NOT NULL,
`sla_resolution_minutes` int(11) DEFAULT NULL,
`sla_created_at` datetime NOT NULL DEFAULT current_timestamp(),
`sla_archived_at` datetime DEFAULT NULL,
PRIMARY KEY (`sla_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci");
// SLA targets are computed once at write time and stored on the ticket, so
// the ticket list and cron/ticket_sla.php only ever compare datetimes. The
// due date indexes bound the cron's every-minute scans the same way the
// 2.4.8 logs index bounded the login lockout queries.
mysqli_query($mysqli, "ALTER TABLE `tickets`
ADD COLUMN `ticket_sla_id` int(11) NOT NULL DEFAULT 0 AFTER `ticket_status`,
ADD COLUMN `ticket_response_due_at` datetime DEFAULT NULL AFTER `ticket_first_response_at`,
ADD COLUMN `ticket_resolution_due_at` datetime DEFAULT NULL AFTER `ticket_response_due_at`,
ADD COLUMN `ticket_response_sla_met` tinyint(1) DEFAULT NULL AFTER `ticket_resolution_due_at`,
ADD COLUMN `ticket_resolution_sla_met` tinyint(1) DEFAULT NULL AFTER `ticket_response_sla_met`,
ADD COLUMN `ticket_response_sla_alert_stage` tinyint(1) NOT NULL DEFAULT 0 AFTER `ticket_resolution_sla_met`,
ADD COLUMN `ticket_resolution_sla_alert_stage` tinyint(1) NOT NULL DEFAULT 0 AFTER `ticket_response_sla_alert_stage`,
ADD INDEX `ticket_response_due_at` (`ticket_response_due_at`),
ADD INDEX `ticket_resolution_due_at` (`ticket_resolution_due_at`)");
// Business hours + SLA notification settings
mysqli_query($mysqli, "ALTER TABLE `settings`
ADD COLUMN `config_business_days` varchar(20) NOT NULL DEFAULT '1,2,3,4,5' AFTER `config_timezone`,
ADD COLUMN `config_business_hours_start` time NOT NULL DEFAULT '09:00:00' AFTER `config_business_days`,
ADD COLUMN `config_business_hours_end` time NOT NULL DEFAULT '17:00:00' AFTER `config_business_hours_start`,
ADD COLUMN `config_sla_warning_percent` tinyint(3) NOT NULL DEFAULT 75 AFTER `config_business_hours_end`,
ADD COLUMN `config_sla_notification_email` varchar(200) DEFAULT NULL AFTER `config_sla_warning_percent`");

View File

@@ -0,0 +1,41 @@
<?php
/*
* ITFlow - Database update to version 2.5.1 (from 2.5.0)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// SLA pausing. sla_history records the intervals during which a ticket's
// resolution clock was actually running - one open row (ended_at NULL) per
// running ticket. Statuses flagged below stop the clock, and the ticket's
// resolution due date is recomputed from the remaining budget on resume.
mysqli_query($mysqli, "CREATE TABLE `sla_history` (
`sla_history_id` int(11) NOT NULL AUTO_INCREMENT,
`sla_history_started_at` datetime NOT NULL,
`sla_history_ended_at` datetime DEFAULT NULL,
`sla_history_minutes` int(11) DEFAULT NULL,
`sla_history_ticket_id` int(11) NOT NULL,
PRIMARY KEY (`sla_history_id`),
KEY `sla_history_ticket_id` (`sla_history_ticket_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci");
// Which statuses pause the resolution clock. Nothing pauses by default, so
// SLA behaviour is unchanged until an admin opts a status in.
mysqli_query($mysqli, "ALTER TABLE `ticket_statuses`
ADD COLUMN `ticket_status_pauses_sla` tinyint(1) NOT NULL DEFAULT 0 AFTER `ticket_status_active`");
// Backfill an open interval for every ticket already running a resolution
// clock, anchored at creation. Without this their consumed time would read
// as zero and the first pause/resume would hand back the full budget.
mysqli_query($mysqli, "INSERT INTO sla_history (sla_history_started_at, sla_history_ticket_id)
SELECT ticket_created_at, ticket_id
FROM tickets
LEFT JOIN slas ON ticket_sla_id = sla_id
WHERE ticket_sla_id > 0
AND sla_resolution_minutes > 0
AND ticket_resolution_due_at IS NOT NULL
AND ticket_resolved_at IS NULL
AND ticket_closed_at IS NULL
AND ticket_archived_at IS NULL");

View File

@@ -0,0 +1,32 @@
<?php
/*
* ITFlow - Database update to version 2.5.2 (from 2.5.1)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Resolving a ticket from the kanban board or the client portal recorded
// the resolution timestamp but never judged the resolution SLA, so those
// tickets carry a target, a resolution time and no verdict. They read as
// still-in-flight and are left out of SLA reporting entirely.
//
// Both timestamps were stored, so the verdict is simply recomputed from
// them. Only rows with no verdict at all are touched - anything already
// judged keeps the answer it was given at the time.
mysqli_query($mysqli, "UPDATE tickets
SET ticket_resolution_sla_met = (ticket_resolved_at <= ticket_resolution_due_at)
WHERE ticket_sla_id > 0
AND ticket_resolution_due_at IS NOT NULL
AND ticket_resolved_at IS NOT NULL
AND ticket_resolution_sla_met IS NULL");
// Same repair on the response track, for any ticket whose first response
// was recorded without the verdict being written alongside it
mysqli_query($mysqli, "UPDATE tickets
SET ticket_response_sla_met = (ticket_first_response_at <= ticket_response_due_at)
WHERE ticket_sla_id > 0
AND ticket_response_due_at IS NOT NULL
AND ticket_first_response_at IS NOT NULL
AND ticket_response_sla_met IS NULL");

View File

@@ -0,0 +1,19 @@
<?php
/*
* ITFlow - Database update to version 2.5.3 (from 2.5.2)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Asset note types
// The asset_notes table itself already ships in db.sql (and picked up its
// foreign key back in 2.0.2) - it was simply never wired to a UI, so the
// only thing missing for multiple notes per asset is the type list
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Maintenance', category_description = 'Routine or scheduled maintenance performed on the asset', category_icon = 'fa-tools', category_type = 'asset_note_type', category_order = 1"); // 1
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Repair', category_description = 'Repair work or hardware replacement', category_icon = 'fa-wrench', category_type = 'asset_note_type', category_order = 2"); // 2
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Configuration', category_description = 'Configuration or settings change made to the asset', category_icon = 'fa-sliders-h', category_type = 'asset_note_type', category_order = 3"); // 3
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Upgrade', category_description = 'Hardware or software upgrade', category_icon = 'fa-arrow-circle-up', category_type = 'asset_note_type', category_order = 4"); // 4
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Inspection', category_description = 'Physical inspection or audit of the asset', category_icon = 'fa-clipboard-check', category_type = 'asset_note_type', category_order = 5"); // 5
mysqli_query($mysqli, "INSERT INTO categories SET category_name = 'Note', category_description = 'General note or internal comment', category_icon = 'fa-sticky-note', category_type = 'asset_note_type', category_order = 6"); // 6

View File

@@ -0,0 +1,16 @@
<?php
/*
* ITFlow - Database update to version 2.5.4 (from 2.5.3)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Recurring tickets can now carry a ticket template. The template's subject
// and details are copied into the recurring ticket when it is picked, so the
// link exists for one reason only: to stamp the template's task list onto
// every ticket the schedule raises. 0 means no template, matching the other
// optional relations on this table.
mysqli_query($mysqli, "ALTER TABLE `recurring_tickets`
ADD COLUMN `recurring_ticket_ticket_template_id` int(11) NOT NULL DEFAULT 0 AFTER `recurring_ticket_asset_id`");

View File

@@ -0,0 +1,24 @@
<?php
/*
* ITFlow - Database update to version 2.5.5 (from 2.5.4)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Backfill guest URL keys. Every path that raises a ticket generates one
// except the recurring block in cron.php, which omitted the column - so
// every ticket the nightly schedule has ever created carries no key. The
// guest view matches on ticket_url_key, so those tickets cannot be opened
// from the "View ticket" link in reply and task-approval emails. It fails
// closed rather than open (NULL never matches), so this is a broken link
// rather than an exposure, but the links stay broken until a key exists.
$sql_tickets_without_url_key = mysqli_query($mysqli, "SELECT ticket_id FROM tickets WHERE ticket_url_key IS NULL OR ticket_url_key = ''");
while ($row = mysqli_fetch_assoc($sql_tickets_without_url_key)) {
$ticket_id = intval($row['ticket_id']);
$url_key = randomString(32);
mysqli_query($mysqli, "UPDATE tickets SET ticket_url_key = '$url_key' WHERE ticket_id = $ticket_id");
}

View File

@@ -0,0 +1,34 @@
<?php
/*
* ITFlow - Database update to version 2.5.6 (from 2.5.5)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Recurring tickets now own their task list rather than reading the linked
// ticket template at every run. The template still fills the list in when it
// is picked, but the list can then be edited per schedule - which is only
// meaningful if the edits are what the run actually reads.
mysqli_query($mysqli, "CREATE TABLE `recurring_ticket_tasks` (
`recurring_ticket_task_id` int(11) NOT NULL AUTO_INCREMENT,
`recurring_ticket_task_name` varchar(255) NOT NULL,
`recurring_ticket_task_order` int(11) NOT NULL DEFAULT 0,
`recurring_ticket_task_completion_estimate` int(11) NOT NULL DEFAULT 0,
`recurring_ticket_task_recurring_ticket_id` int(11) NOT NULL,
PRIMARY KEY (`recurring_ticket_task_id`),
KEY `recurring_ticket_task_recurring_ticket_id` (`recurring_ticket_task_recurring_ticket_id`),
CONSTRAINT `recurring_ticket_tasks_ibfk_1` FOREIGN KEY (`recurring_ticket_task_recurring_ticket_id`) REFERENCES `recurring_tickets` (`recurring_ticket_id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci");
// Copy each linked template's current tasks onto the schedule that links it, so
// every existing recurring ticket keeps raising exactly the tasks it raises
// today. Without this the switch to an owned list would silently produce
// taskless tickets on the next run.
mysqli_query($mysqli, "INSERT INTO recurring_ticket_tasks
(recurring_ticket_task_name, recurring_ticket_task_order, recurring_ticket_task_completion_estimate, recurring_ticket_task_recurring_ticket_id)
SELECT task_template_name, task_template_order, task_template_completion_estimate, recurring_ticket_id
FROM recurring_tickets
INNER JOIN task_templates ON task_template_ticket_template_id = recurring_ticket_ticket_template_id
WHERE recurring_ticket_ticket_template_id > 0");

View File

@@ -0,0 +1,16 @@
<?php
/*
* ITFlow - Database update to version 2.5.7 (from 2.5.6)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Queued mail can now carry file attachments. The column holds a JSON manifest
// of app-root-relative paths and display names rather than file contents, so
// the queue table stays small and the files stay in uploads/ where the ticket
// attachment endpoints already serve them from. It is scrubbed on delivery
// alongside the body, like email_cal_str.
mysqli_query($mysqli, "ALTER TABLE `email_queue`
ADD COLUMN `email_attachments` text DEFAULT NULL AFTER `email_cal_str`");

View File

@@ -0,0 +1,27 @@
<?php
/*
* ITFlow - Database update to version 2.5.8 (from 2.5.7)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Calendars can now be published as a read-only ICS feed for Google Calendar,
// Nextcloud and any other subscription client. The key is stored in cleartext,
// matching invoice_url_key and shared_items.item_key - hashing it would buy
// nothing here (anyone holding the database already holds the events the key
// grants access to) and would make the URL impossible to re-copy for a second
// device without breaking every existing subscriber.
//
// The UNIQUE key is on a nullable column, so unshared calendars all keep NULL.
// The column is explicitly utf8mb4_bin: the table default is utf8mb4_general_ci,
// which compares case-insensitively, and the key alphabet is mixed-case
// base64url - a _ci column would throw away entropy on lookup and make the
// UNIQUE index blind to case.
mysqli_query($mysqli, "ALTER TABLE `calendars`
ADD COLUMN `calendar_feed_key` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL AFTER `calendar_color`,
ADD COLUMN `calendar_feed_busy_only` tinyint(1) NOT NULL DEFAULT 0 AFTER `calendar_feed_key`,
ADD COLUMN `calendar_feed_created_at` datetime DEFAULT NULL AFTER `calendar_feed_busy_only`,
ADD COLUMN `calendar_feed_accessed_at` datetime DEFAULT NULL AFTER `calendar_feed_created_at`,
ADD UNIQUE KEY `calendar_feed_key` (`calendar_feed_key`)");

View File

@@ -0,0 +1,28 @@
<?php
/*
* ITFlow - Database update to version 2.5.9 (from 2.5.8)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Events can now be marked all-day explicitly. Until now calendar_events had
// no all-day column at all - FullCalendar inferred it from a start value with
// no time component, which meant a genuine midnight appointment was
// indistinguishable from an all-day event.
mysqli_query($mysqli, "ALTER TABLE `calendar_events`
ADD COLUMN `event_all_day` tinyint(1) NOT NULL DEFAULT 0 AFTER `event_end`");
// Backfill using the same rule the calendar already rendered by, so existing
// events keep displaying exactly as they do today: a midnight start, and
// either no end or a midnight end.
mysqli_query($mysqli, "UPDATE `calendar_events`
SET `event_all_day` = 1
WHERE TIME(`event_start`) = '00:00:00'
AND (`event_end` IS NULL OR TIME(`event_end`) = '00:00:00')");
// Corrects the collation on installs that already applied 2.5.8 before the
// column was pinned to utf8mb4_bin. Harmless to re-run.
mysqli_query($mysqli, "ALTER TABLE `calendars`
MODIFY COLUMN `calendar_feed_key` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL");

View File

@@ -0,0 +1,26 @@
<?php
/*
* ITFlow - Database update to version 2.6.0 (from 2.5.9)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// cron/cron.php is now a dispatcher: it runs every minute and decides which of the
// scripts in cron/ are due, so the crontab only needs one line. That decision needs
// somewhere durable to record when each job last ran - a job whose minute was missed
// has to be picked up at the next opportunity rather than skipped, and one that is
// half way through must not be started again.
//
// Rows are created by the dispatcher the first time it sees a job, so adding a job
// later needs a line in cron.php and nothing here.
mysqli_query($mysqli, "CREATE TABLE IF NOT EXISTS `cron_jobs` (
`cron_job_id` int(11) NOT NULL AUTO_INCREMENT,
`cron_job_name` varchar(200) NOT NULL,
`cron_job_last_run_at` datetime DEFAULT NULL,
`cron_job_last_finished_at` datetime DEFAULT NULL,
`cron_job_last_status` varchar(200) DEFAULT NULL,
PRIMARY KEY (`cron_job_id`),
UNIQUE KEY `cron_job_name` (`cron_job_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci");

View File

@@ -0,0 +1,32 @@
<?php
/*
* ITFlow - Database update to version 2.6.1 (from 2.6.0)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// The cron dispatcher's schedule moves out of code and into the database so it can be
// managed from Maintenance > Cron. The registry in includes/cron_jobs.php still decides
// which scripts exist and seeds these columns the first time it meets a job; from then
// on the row is what runs. Nothing here can name a script - a row whose job is not in
// the registry is ignored.
mysqli_query($mysqli, "ALTER TABLE `cron_jobs`
ADD COLUMN `cron_job_enabled` tinyint(1) NOT NULL DEFAULT 1 AFTER `cron_job_name`,
ADD COLUMN `cron_job_schedule` varchar(200) NOT NULL DEFAULT 'Interval' AFTER `cron_job_enabled`,
ADD COLUMN `cron_job_interval_minutes` int(11) NOT NULL DEFAULT 1 AFTER `cron_job_schedule`,
ADD COLUMN `cron_job_daily_at` time DEFAULT NULL AFTER `cron_job_interval_minutes`,
ADD COLUMN `cron_job_run_now` tinyint(1) NOT NULL DEFAULT 0 AFTER `cron_job_daily_at`");
// Duration is here to make a job that is quietly getting slower visible before it starts
// overrunning its own interval.
mysqli_query($mysqli, "ALTER TABLE `cron_jobs`
ADD COLUMN `cron_job_last_duration` decimal(10,2) DEFAULT NULL AFTER `cron_job_last_finished_at`,
ADD COLUMN `cron_job_last_error` text DEFAULT NULL AFTER `cron_job_last_status`,
ADD COLUMN `cron_job_last_error_at` datetime DEFAULT NULL AFTER `cron_job_last_error`");
// Written by the dispatcher every minute before it runs anything, so the admin page can
// tell "no job happened to be due" apart from "the crontab entry is missing".
mysqli_query($mysqli, "ALTER TABLE `settings`
ADD COLUMN `config_cron_last_dispatch_at` datetime DEFAULT NULL");

View File

@@ -0,0 +1,38 @@
<?php
/*
* ITFlow - Database update to version 2.6.2 (from 2.6.1)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Two things, both about rows that already existed before 2.6.1 ran.
//
// 2.6.1 added the schedule columns but could not set them, so those rows took the column
// defaults - every minute - which is wrong for three jobs and harmful for the nightly run,
// whose overdue invoice reminders re-send on every pass. The registry in
// includes/cron_jobs.php only ever seeds a row it is creating, so it cannot fix them.
//
// The domain refresher also moves onto the nightly schedule here, which is why it is
// matched at either of the intervals it may be sitting on: 1 from the column default, or 5
// if it was already put back by hand.
//
// Only rows still carrying one of those shipped values are touched, so a schedule someone
// has deliberately changed is left alone. Deliberately hardcoded rather than read from the
// registry: a migration has to keep meaning the same thing years from now, whatever that
// file says by then.
mysqli_query($mysqli, "UPDATE `cron_jobs`
SET `cron_job_schedule` = 'Daily', `cron_job_daily_at` = '03:00:00'
WHERE `cron_job_name` = 'nightly_tasks'
AND `cron_job_schedule` = 'Interval' AND `cron_job_interval_minutes` = 1");
mysqli_query($mysqli, "UPDATE `cron_jobs`
SET `cron_job_schedule` = 'Daily', `cron_job_daily_at` = '03:30:00'
WHERE `cron_job_name` = 'certificate_refresher'
AND `cron_job_schedule` = 'Interval' AND `cron_job_interval_minutes` = 1");
mysqli_query($mysqli, "UPDATE `cron_jobs`
SET `cron_job_schedule` = 'Daily', `cron_job_daily_at` = '04:00:00'
WHERE `cron_job_name` = 'domain_refresher'
AND `cron_job_schedule` = 'Interval' AND `cron_job_interval_minutes` IN (1, 5)");

View File

@@ -0,0 +1,13 @@
<?php
/*
* ITFlow - Database update to version 2.6.3 (from 2.6.2)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Add Indexes to Ticket Replies foreign key to ticket_id and same with Attachment Fixes a slow down issue with many Replies with large bodies.
mysqli_query($mysqli, "ALTER TABLE ticket_replies ADD INDEX (ticket_reply_ticket_id, ticket_reply_archived_at)");
mysqli_query($mysqli, "ALTER TABLE ticket_attachments ADD INDEX (ticket_attachment_reply_id)");

View File

@@ -0,0 +1,38 @@
<?php
/*
* ITFlow - Database update to version 2.6.4 (from 2.6.3)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Backup catalogue - one row per archive produced, so the app knows what exists
// without trusting a directory listing
mysqli_query($mysqli, "CREATE TABLE IF NOT EXISTS `backups` (
`backup_id` int(11) NOT NULL AUTO_INCREMENT,
`backup_type` varchar(20) NOT NULL DEFAULT 'full',
`backup_file_name` varchar(255) NOT NULL,
`backup_size` bigint(20) NOT NULL DEFAULT 0,
`backup_sha256` varchar(64) DEFAULT NULL,
`backup_status` varchar(20) NOT NULL DEFAULT 'Pending',
`backup_error` text DEFAULT NULL,
`backup_source` varchar(20) NOT NULL DEFAULT 'Manual',
`backup_created_by` varchar(200) DEFAULT NULL,
`backup_created_at` datetime NOT NULL DEFAULT current_timestamp(),
`backup_completed_at` datetime DEFAULT NULL,
`backup_downloaded_at` datetime DEFAULT NULL,
PRIMARY KEY (`backup_id`),
KEY `backup_status_created` (`backup_status`, `backup_created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci");
// Retention and what the scheduled backup produces
mysqli_query($mysqli, "ALTER TABLE settings ADD COLUMN IF NOT EXISTS `config_backup_retention_days` int(11) NOT NULL DEFAULT 30");
mysqli_query($mysqli, "ALTER TABLE settings ADD COLUMN IF NOT EXISTS `config_backup_retention_count` int(11) NOT NULL DEFAULT 5");
mysqli_query($mysqli, "ALTER TABLE settings ADD COLUMN IF NOT EXISTS `config_backup_cron_type` varchar(20) NOT NULL DEFAULT 'full'");
// Seed the scheduled backup job. The dispatcher would create this row itself the first
// time it sees the job, but seeding it here means the schedule is right on an install
// that already has cron_jobs rows - the every-minute default bit us once already.
mysqli_query($mysqli, "INSERT IGNORE INTO cron_jobs SET cron_job_name = 'backup', cron_job_enabled = 0, cron_job_schedule = 'Daily', cron_job_daily_at = '02:00'");

View File

@@ -0,0 +1,26 @@
<?php
/*
* ITFlow - Database update to version 2.6.5 (from 2.6.4)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// credential_password was inherited as VARBINARY(200) when 2.0.0 renamed login_password.
// What it actually stores is a 16-char IV followed by base64 AES-128-CBC ciphertext -
// pure ASCII, the same shape as credential_username (varchar(500)) and
// users.user_specific_encryption_ciphertext (varchar(200)). Nothing compares, indexes,
// sorts or searches on the column, so binary semantics were never buying anything.
//
// The width was the real problem: base64 expands ~1.37x, so 200 bytes capped the
// cleartext at 127 chars while the credential form offered 350. Anything longer
// overflowed and errored the save. varchar(500) matches credential_username and makes
// 350 the correct form limit for both fields.
// Widen while still binary first. If the charset conversion below fails on an install
// with unexpected bytes, the column is at least already wide enough and the app keeps
// working - varbinary(500) holds the same values just fine.
mysqli_query($mysqli, "ALTER TABLE `credentials` MODIFY `credential_password` varbinary(500) DEFAULT NULL");
mysqli_query($mysqli, "ALTER TABLE `credentials` MODIFY `credential_password` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL");

View File

@@ -0,0 +1,19 @@
<?php
/*
* ITFlow - Database update to version 2.6.6 (from 2.6.5)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// config_invoice_overdue_reminders never did anything. Both setup paths seeded it with
// '1,3,7' and two files read it, but the only line that would have used it was commented
// out in the nightly job - the reminder schedule is a hardcoded array. The read in
// includes/load_global_settings.php also ran intval() over a comma-separated string, so
// the global was 1 whatever the column said.
//
// Nothing in the app has ever written to it either, so no install has a value worth
// keeping and there is nothing to migrate anywhere.
mysqli_query($mysqli, "ALTER TABLE `settings` DROP COLUMN IF EXISTS `config_invoice_overdue_reminders`");

View File

@@ -0,0 +1,18 @@
<?php
/*
* ITFlow - Database update to version 2.6.7 (from 2.6.6)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// The AI endpoints used to send a hardcoded temperature (0.5, or 0.3 for ticket
// summaries). Newer OpenAI models accept nothing but their own default and reject
// the request outright, which surfaced as "Failed to get a response from the AI API".
//
// Temperature is now per-model and optional: NULL means don't send the parameter
// at all, which is the setting that works on every provider. Existing rows get
// NULL so they stop sending it.
mysqli_query($mysqli, "ALTER TABLE `ai_models` ADD COLUMN IF NOT EXISTS `ai_model_temperature` decimal(3,2) DEFAULT NULL AFTER `ai_model_use_case`");

View File

@@ -79,7 +79,7 @@ $phpConfig[] = [
];
// Check upload_max_filesize and post_max_size >= 500M
function return_bytes($val) {
function toBytes($val) {
$val = trim($val);
$unit = strtolower(substr($val, -1));
$num = (float)$val;
@@ -99,8 +99,8 @@ $required_bytes = 500 * 1024 * 1024; // 500M in bytes
$upload_max_filesize = ini_get('upload_max_filesize');
$post_max_size = ini_get('post_max_size');
$upload_passed = return_bytes($upload_max_filesize) >= $required_bytes;
$post_passed = return_bytes($post_max_size) >= $required_bytes;
$upload_passed = toBytes($upload_max_filesize) >= $required_bytes;
$post_passed = toBytes($post_max_size) >= $required_bytes;
$phpConfig[] = [
'name' => 'upload_max_filesize >= 500M',
@@ -116,7 +116,7 @@ $phpConfig[] = [
// PHP Memory Limit >= 128M
$memoryLimit = ini_get('memory_limit');
$memoryLimitBytes = return_bytes($memoryLimit);
$memoryLimitBytes = toBytes($memoryLimit);
$memoryLimitPassed = $memoryLimitBytes >= (128 * 1024 * 1024);
$phpConfig[] = [
'name' => 'PHP Memory Limit >= 128M',
@@ -147,7 +147,7 @@ $phpConfig[] = [
$shellCommands = [];
if ($shell_exec_enabled) {
$commands = ['whois', 'dig', 'git'];
$commands = ['git'];
foreach ($commands as $command) {
$which = trim(shell_exec("which $command 2>/dev/null"));
@@ -160,7 +160,7 @@ if ($shell_exec_enabled) {
}
} else {
// If shell_exec is disabled, mark commands as unavailable
foreach (['whois', 'dig', 'git'] as $command) {
foreach (['git'] as $command) {
$shellCommands[] = [
'name' => "Command '$command' available",
'passed' => false,
@@ -523,19 +523,19 @@ $mysqli->close();
<table class="table table-bordered mb-3">
<tr>
<th>ITFlow release version</th>
<th><?php echo APP_VERSION; ?></th>
<th><?= APP_VERSION ?></th>
</tr>
<tr>
<td>Current DB Version</td>
<td><?php echo CURRENT_DATABASE_VERSION; ?></td>
<td><?= CURRENT_DATABASE_VERSION ?></td>
</tr>
<tr>
<td>Current Code Commit</td>
<td><?php echo $commitHash; ?></td>
<td><?= $commitHash ?></td>
</tr>
<tr>
<td>Current Branch</td>
<td><?php echo $gitBranch; ?></td>
<td><?= $gitBranch ?></td>
</tr>
</table>
</div>

View File

@@ -1,137 +1,71 @@
<?php
// Default Column Sort by Filter
$sort = "document_template_name";
$order = "ASC";
require_once "includes/inc_all_admin.php";
require_once "includes/inc_all_admin.php";
$sql = mysqli_query(
$mysqli,
"SELECT SQL_CALC_FOUND_ROWS * FROM document_templates
LEFT JOIN users ON document_template_created_by = user_id
WHERE user_name LIKE '%$q%' OR document_template_name LIKE '%$q%'
ORDER BY $sort $order LIMIT $record_from, $record_to"
);
//Initialize the HTML Purifier to prevent XSS
require "../libs/htmlpurifier/HTMLPurifier.standalone.php";
$num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
$purifier_config = HTMLPurifier_Config::createDefault();
$purifier_config->set('Cache.DefinitionImpl', null); // Disable cache by setting a non-existent directory or an invalid one
$purifier_config->set('URI.AllowedSchemes', ['data' => true, 'src' => true, 'http' => true, 'https' => true]);
$purifier = new HTMLPurifier($purifier_config);
if (isset($_GET['document_template_id'])) {
$document_template_id = intval($_GET['document_template_id']);
}
$sql_document = mysqli_query($mysqli, "SELECT document_template_content, document_template_created_at, document_template_description,
document_template_name, document_template_updated_at FROM document_templates WHERE document_template_id = $document_template_id LIMIT 1");
if (mysqli_num_rows($sql_document) == 0) {
echo "<center><h1 class='text-secondary mt-5'>Nothing to see here</h1><a class='btn btn-lg btn-secondary mt-3' href='javascript:history.back()'><i class='fa fa-fw fa-arrow-left'></i> Go Back</a></center>";
require_once "../includes/footer.php";
exit();
}
$row = mysqli_fetch_assoc($sql_document);
$document_template_name = escapeHtml($row['document_template_name']);
$document_template_description = escapeHtml($row['document_template_description']);
$document_template_content = $purifier->purify($row['document_template_content']);
$document_template_created_at = escapeHtml($row['document_template_created_at']);
$document_template_updated_at = escapeHtml($row['document_template_updated_at']);
?>
<ol class="breadcrumb d-print-none">
<li class="breadcrumb-item">
<a href="../">Home</a>
</li>
<li class="breadcrumb-item">
<a href="users.php">Admin</a>
</li>
<li class="breadcrumb-item">
<a href="document_templates.php">Document Templates</a>
</li>
<li class="breadcrumb-item active"><i class="fas fa-file-alt mr-2"></i><?= $document_template_name ?></li>
</ol>
<div class="card card-dark">
<div class="card-header py-2">
<h3 class="card-title mt-2"><i class="fa fa-fw fa-file-alt mr-2"></i>Document Templates</h3>
<div class="card-header">
<h3 class="card-title mt-1"><i class="fa fa-fw fa-file-alt mr-2"></i><?= $document_template_name ?></h3>
<div class="card-tools">
<button type="button" class="btn btn-primary ajax-modal" data-modal-url="modals/document_template/document_template_add.php" data-modal-size="xl">
<i class="fas fa-plus mr-2"></i>New Template
<button type="button" class="btn btn-tool ajax-modal"
data-modal-size="xl"
data-modal-url="modals/document_template/document_template_edit.php?id=<?= $document_template_id ?>">
<i class="fas fa-edit mr-2"></i>
</button>
</div>
</div>
<div class="card-body">
<form autocomplete="off">
<div class="input-group">
<input type="search" class="form-control " name="q" value="<?php if (isset($q)) { echo stripslashes(nullable_htmlentities($q)); } ?>" placeholder="Search templates">
<div class="input-group-append">
<button class="btn btn-secondary"><i class="fa fa-search"></i></button>
</div>
</div>
</form>
<hr>
<div class="table-responsive-sm">
<table class="table table-striped table-borderless table-hover">
<thead class="text-dark <?php if ($num_rows[0] == 0) { echo "d-none"; } ?>">
<tr>
<th>
<a class="text-secondary" href="?<?php echo $url_query_strings_sort; ?>&sort=document_template_name&order=<?php echo $disp; ?>">
Template Name <?php if ($sort == 'document_template_name') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-secondary" href="?<?php echo $url_query_strings_sort; ?>&sort=document_template_created_at&order=<?php echo $disp; ?>">
Created <?php if ($sort == 'document_template_created_at') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-secondary" href="?<?php echo $url_query_strings_sort; ?>&sort=document_template_updated_at&order=<?php echo $disp; ?>">
Updated <?php if ($sort == 'document_template_updated_at') { echo $order_icon; } ?>
</a>
</th>
<th class="text-center">
Action
</th>
</tr>
</thead>
<tbody>
<?php
while ($row = mysqli_fetch_assoc($sql)) {
$document_template_id = intval($row['document_template_id']);
$document_template_name = nullable_htmlentities($row['document_template_name']);
$document_template_description = nullable_htmlentities($row['document_template_description']);
$document_template_content = nullable_htmlentities($row['document_template_content']);
$document_template_created_by_name = nullable_htmlentities($row['user_name']);
$document_template_created_at = nullable_htmlentities($row['document_template_created_at']);
$document_template_updated_at = nullable_htmlentities(getFallback($row['document_template_updated_at']));
?>
<tr>
<td>
<a class="text-dark ajax-modal" href="#"
data-modal-size="xl"
data-modal-url="modals/document_template/document_template_edit.php?id=<?= $document_template_id ?>">
<div class="media">
<i class="fas fa-fw fa-2x fa-file-alt mr-2"></i>
<div class="media-body">
<div><?= $document_template_name ?></div>
<div><small class="text-secondary"><?= $document_template_description ?></small></div>
</div>
</div>
</a>
</td>
<td>
<?php echo $document_template_created_at; ?>
<div class="text-secondary"><?php echo $document_template_created_by_name; ?></div>
</td>
<td><?php echo $document_template_updated_at; ?></td>
<td>
<div class="dropdown dropleft text-center">
<button class="btn btn-secondary btn-sm" type="button" data-toggle="dropdown">
<i class="fas fa-ellipsis-h"></i>
</button>
<div class="dropdown-menu">
<a class="dropdown-item" href="document_template_details.php?document_template_id=<?= $document_template_id ?>">
<i class="fas fa-fw fa-eye mr-2"></i>View
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item ajax-modal" href="#"
data-modal-size="xl"
data-modal-url="modals/document_template/document_template_edit.php?id=<?= $document_template_id ?>">
<i class="fas fa-fw fa-edit mr-2"></i>Edit
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger text-bold" href="post.php?delete_document_template=<?php echo $document_template_id; ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-trash mr-2"></i>Delete
</a>
</div>
</div>
</td>
</tr>
<?php
}
?>
</tbody>
</table>
<br>
</div>
<?php require_once "../includes/filter_footer.php"; ?>
<div class="card-body prettyContent">
<?= $document_template_content ?>
</div>
</div>
<?php require_once "../includes/footer.php";
<script src="../js/pretty_content.js"></script>
<?php
require_once "../includes/footer.php";

View File

@@ -1,70 +0,0 @@
<?php
require_once "includes/inc_all_admin.php";
//Initialize the HTML Purifier to prevent XSS
require "../plugins/htmlpurifier/HTMLPurifier.standalone.php";
$purifier_config = HTMLPurifier_Config::createDefault();
$purifier_config->set('Cache.DefinitionImpl', null); // Disable cache by setting a non-existent directory or an invalid one
$purifier_config->set('URI.AllowedSchemes', ['data' => true, 'src' => true, 'http' => true, 'https' => true]);
$purifier = new HTMLPurifier($purifier_config);
if (isset($_GET['document_template_id'])) {
$document_template_id = intval($_GET['document_template_id']);
}
$sql_document = mysqli_query($mysqli, "SELECT * FROM document_templates WHERE document_template_id = $document_template_id LIMIT 1");
if (mysqli_num_rows($sql_document) == 0) {
echo "<center><h1 class='text-secondary mt-5'>Nothing to see here</h1><a class='btn btn-lg btn-secondary mt-3' href='javascript:history.back()'><i class='fa fa-fw fa-arrow-left'></i> Go Back</a></center>";
require_once "../includes/footer.php";
exit();
}
$row = mysqli_fetch_assoc($sql_document);
$document_template_name = nullable_htmlentities($row['document_template_name']);
$document_template_description = nullable_htmlentities($row['document_template_description']);
$document_template_content = $purifier->purify($row['document_template_content']);
$document_template_created_at = nullable_htmlentities($row['document_template_created_at']);
$document_template_updated_at = nullable_htmlentities($row['document_template_updated_at']);
?>
<ol class="breadcrumb d-print-none">
<li class="breadcrumb-item">
<a href="../">Home</a>
</li>
<li class="breadcrumb-item">
<a href="users.php">Admin</a>
</li>
<li class="breadcrumb-item">
<a href="document_template.php">Document Templates</a>
</li>
<li class="breadcrumb-item active"><i class="fas fa-file-alt mr-2"></i><?php echo $document_template_name; ?></li>
</ol>
<div class="card card-dark">
<div class="card-header">
<h3 class="card-title mt-1"><i class="fa fa-fw fa-file-alt mr-2"></i><?php echo $document_template_name; ?></h3>
<div class="card-tools">
<button type="button" class="btn btn-tool ajax-modal"
data-modal-size="xl"
data-modal-url="modals/document_template/document_template_edit.php?id=<?= $document_template_id ?>">
<i class="fas fa-edit mr-2"></i>
</button>
</div>
</div>
<div class="card-body prettyContent">
<?php echo $document_template_content; ?>
</div>
</div>
<script src="../js/pretty_content.js"></script>
<?php
require_once "../includes/footer.php";

View File

@@ -0,0 +1,138 @@
<?php
// Default Column Sort by Filter
$sort = "document_template_name";
$order = "ASC";
require_once "includes/inc_all_admin.php";
$sql = mysqli_query(
$mysqli,
"SELECT SQL_CALC_FOUND_ROWS document_template_content, document_template_created_at, document_template_description,
document_template_id, document_template_name, document_template_updated_at, user_name FROM document_templates
LEFT JOIN users ON document_template_created_by = user_id
WHERE user_name LIKE '%$q%' OR document_template_name LIKE '%$q%'
ORDER BY $sort $order LIMIT $record_from, $record_to"
);
$num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
?>
<div class="card card-dark">
<div class="card-header py-2">
<h3 class="card-title mt-2"><i class="fa fa-fw fa-file-alt mr-2"></i>Document Templates</h3>
<div class="card-tools">
<button type="button" class="btn btn-primary ajax-modal" data-modal-url="modals/document_template/document_template_add.php" data-modal-size="xl">
<i class="fas fa-plus mr-2"></i>New Template
</button>
</div>
</div>
<div class="card-body">
<form autocomplete="off">
<div class="input-group">
<input type="search" class="form-control " name="q" value="<?php if (isset($q)) { echo stripslashes(escapeHtml($q)); } ?>" placeholder="Search templates">
<div class="input-group-append">
<button class="btn btn-secondary"><i class="fa fa-search"></i></button>
</div>
</div>
</form>
<hr>
<div class="table-responsive-sm">
<table class="table table-striped table-borderless table-hover">
<thead class="text-dark <?php if ($num_rows[0] == 0) { echo "d-none"; } ?>">
<tr>
<th>
<a class="text-secondary" href="?<?= $url_query_strings_sort ?>&sort=document_template_name&order=<?= $disp ?>">
Template Name <?php if ($sort == 'document_template_name') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-secondary" href="?<?= $url_query_strings_sort ?>&sort=document_template_created_at&order=<?= $disp ?>">
Created <?php if ($sort == 'document_template_created_at') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-secondary" href="?<?= $url_query_strings_sort ?>&sort=document_template_updated_at&order=<?= $disp ?>">
Updated <?php if ($sort == 'document_template_updated_at') { echo $order_icon; } ?>
</a>
</th>
<th class="text-center">
Action
</th>
</tr>
</thead>
<tbody>
<?php
while ($row = mysqli_fetch_assoc($sql)) {
$document_template_id = intval($row['document_template_id']);
$document_template_name = escapeHtml($row['document_template_name']);
$document_template_description = escapeHtml($row['document_template_description']);
$document_template_content = escapeHtml($row['document_template_content']);
$document_template_created_by_name = escapeHtml($row['user_name']);
$document_template_created_at = escapeHtml($row['document_template_created_at']);
$document_template_updated_at = escapeHtml($row['document_template_updated_at']) ?: '-';
?>
<tr>
<td>
<a class="text-dark ajax-modal" href="#"
data-modal-size="xl"
data-modal-url="modals/document_template/document_template_edit.php?id=<?= $document_template_id ?>">
<div class="media">
<i class="fas fa-fw fa-2x fa-file-alt mr-2"></i>
<div class="media-body">
<div><?= $document_template_name ?></div>
<div><small class="text-secondary"><?= $document_template_description ?></small></div>
</div>
</div>
</a>
</td>
<td>
<?= $document_template_created_at ?>
<div class="text-secondary"><?= $document_template_created_by_name ?></div>
</td>
<td><?= $document_template_updated_at ?></td>
<td>
<div class="dropdown dropleft text-center">
<button class="btn btn-secondary btn-sm" type="button" data-toggle="dropdown">
<i class="fas fa-ellipsis-h"></i>
</button>
<div class="dropdown-menu">
<a class="dropdown-item" href="document_template.php?document_template_id=<?= $document_template_id ?>">
<i class="fas fa-fw fa-eye mr-2"></i>View
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item ajax-modal" href="#"
data-modal-size="xl"
data-modal-url="modals/document_template/document_template_edit.php?id=<?= $document_template_id ?>">
<i class="fas fa-fw fa-edit mr-2"></i>Edit
</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger text-bold" href="post.php?delete_document_template=<?= $document_template_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>">
<i class="fas fa-fw fa-trash mr-2"></i>Delete
</a>
</div>
</div>
</td>
</tr>
<?php
}
?>
</tbody>
</table>
<br>
</div>
<?php require_once "../includes/filter_footer.php"; ?>
</div>
</div>
<?php require_once "../includes/footer.php";

View File

@@ -8,7 +8,7 @@ require_once "includes/inc_all_admin.php";
</div>
<div class="card-body">
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?php echo $_SESSION['csrf_token'] ?>">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<h4>Client Portal SSO via Microsoft Entra</h4>
@@ -33,7 +33,7 @@ require_once "includes/inc_all_admin.php";
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-user"></i></span>
</div>
<input type="text" class="form-control" name="azure_client_id" placeholder="e721e3b6-01d6-50e8-7f22-c84d951a52e7" value="<?php echo nullable_htmlentities($config_azure_client_id); ?>">
<input type="text" class="form-control" name="azure_client_id" placeholder="e721e3b6-01d6-50e8-7f22-c84d951a52e7" maxlength="200" value="<?= escapeHtml($config_azure_client_id) ?>">
</div>
</div>
@@ -43,7 +43,7 @@ require_once "includes/inc_all_admin.php";
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-key"></i></span>
</div>
<input type="password" class="form-control" name="azure_client_secret" placeholder="Auto-generated from App Registration" value="<?php echo nullable_htmlentities($config_azure_client_secret); ?>" autocomplete="new-password">
<input type="password" class="form-control" name="azure_client_secret" placeholder="Auto-generated from App Registration" maxlength="200" value="<?= escapeHtml($config_azure_client_secret) ?>" autocomplete="new-password">
</div>
</div>

View File

@@ -4,9 +4,7 @@ require_once $_SERVER['DOCUMENT_ROOT'] . '/config.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/functions.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/includes/check_login.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/includes/page_title.php';
if (!isset($session_is_admin) || !$session_is_admin) {
exit(WORDING_ROLECHECK_FAILED . "<br>Tell your admin: Your role does not have admin access.");
}
enforceAdminPermission();
require_once $_SERVER['DOCUMENT_ROOT'] . '/includes/header.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/includes/top_nav.php';
require_once 'includes/side_nav.php';

View File

@@ -0,0 +1,4 @@
<?php
require_once $_SERVER['DOCUMENT_ROOT'] . '/includes/modal_header.php';
enforceAdminPermission();

View File

@@ -1,6 +1,6 @@
<!-- Main Sidebar Container -->
<aside class="main-sidebar sidebar-dark-<?php echo nullable_htmlentities($config_theme); ?> d-print-none">
<a class="brand-link pb-1 mt-1" href="/agent/<?php echo $config_start_page ?>">
<aside class="main-sidebar sidebar-dark-<?= escapeHtml($config_theme) ?> d-print-none">
<a class="brand-link pb-1 mt-1" href="/agent/<?= $config_start_page ?>">
<p class="h6">
<i class="nav-icon fas fa-arrow-left ml-3 mr-2"></i>
<span class="brand-text">
@@ -41,70 +41,96 @@
<p>API Keys</p>
</a>
</li>
<li class="nav-header">TAGS & CATEGORIES</li>
<li class="nav-header">LISTS</li>
<li class="nav-item">
<a href="/admin/tag.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'tag.php' ? 'active' : ''); ?>">
<a href="/admin/tags.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'tags.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-tags"></i>
<p>Tags</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/category.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'category.php' ? 'active' : ''); ?>">
<a href="/admin/categories.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'categories.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-list-ul"></i>
<p>Categories</p>
</a>
</li>
<?php if ($config_module_enable_accounting) { ?>
<li class="nav-item">
<a href="/admin/tax.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'tax.php' ? 'active' : ''); ?>">
<a href="/admin/tax_rates.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'tax_rates.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-balance-scale"></i>
<p>Taxes</p>
<p>Tax Rates</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/payment_method.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'payment_method.php' ? 'active' : ''); ?>">
<a href="/admin/payment_methods.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'payment_methods.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-hand-holding-usd"></i>
<p>Payment Methods</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/payment_provider.php"
class="nav-link <?php echo (in_array(basename($_SERVER['PHP_SELF']), ['payment_provider.php', 'saved_payment_method.php']) ? 'active' : ''); ?>">
<i class="nav-icon far fa-credit-card"></i>
<p>Payment Providers</p>
</a>
</li>
<?php } ?>
<li class="nav-item">
<a href="/admin/ai_provider.php"
class="nav-link <?php echo (in_array(basename($_SERVER['PHP_SELF']), ['ai_provider.php', 'ai_model.php']) ? 'active' : ''); ?>">
<i class="nav-icon fas fa-robot"></i>
<p>AI Providers</p>
</a>
</li>
<?php if ($config_module_enable_ticketing) { ?>
<li class="nav-item">
<a href="/admin/ticket_status.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'ticket_status.php' ? 'active' : ''); ?>">
<a href="/admin/ticket_statuses.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'ticket_statuses.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-info-circle"></i>
<p>Ticket Statuses</p>
</a>
</li>
<?php } ?>
<?php if ($config_module_enable_ticketing) { ?>
<li class="nav-item">
<a href="/admin/sla.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'sla.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-stopwatch"></i>
<p>SLAs</p>
</a>
</li>
<?php } ?>
<li class="nav-header">INTEGRATIONS</li>
<?php if ($config_module_enable_accounting) { ?>
<li class="nav-item">
<a href="/admin/payment_providers.php"
class="nav-link <?= (in_array(basename($_SERVER['PHP_SELF']), ['payment_providers.php', 'saved_payment_methods.php']) ? 'active' : '') ?>">
<i class="nav-icon far fa-credit-card"></i>
<p>Payment Providers</p>
</a>
</li>
<?php } ?>
<li class="nav-item">
<a href="/admin/custom_link.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'custom_link.php' ? 'active' : ''); ?>">
<a href="/admin/ai_providers.php"
class="nav-link <?= (in_array(basename($_SERVER['PHP_SELF']), ['ai_providers.php', 'ai_models.php']) ? 'active' : '') ?>">
<i class="nav-icon fas fa-robot"></i>
<p>AI Providers</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/custom_links.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'custom_links.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-external-link-alt"></i>
<p>Custom Links</p>
</a>
</li>
<?php if ($config_module_enable_itdoc) { ?>
<?php if ($config_module_enable_itdoc || $config_module_enable_ticketing) { ?>
<li class="nav-header">TEMPLATES</li>
<?php } ?>
<?php if ($config_module_enable_ticketing) { ?>
<li class="nav-item">
<a href="/admin/project_templates.php" class="nav-link <?= (in_array(basename($_SERVER['PHP_SELF']), ['project_templates.php', 'project_template.php']) ? 'active' : '') ?>">
<i class="nav-icon fas fa-project-diagram"></i>
<p>Project Templates</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/ticket_templates.php" class="nav-link <?= (in_array(basename($_SERVER['PHP_SELF']), ['ticket_templates.php', 'ticket_template.php']) ? 'active' : '') ?>">
<i class="nav-icon fas fa-life-ring"></i>
<p>Ticket Templates</p>
</a>
</li>
<?php } ?>
<?php if ($config_module_enable_itdoc) { ?>
<!-- 2025-11-16 JQ - Hide Contracts not yet ready
<li class="nav-item">
<a href="/admin/contract_template.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'contract_template.php' ? 'active' : ''); ?>">
<a href="/admin/contract_templates.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'contract_templates.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-file-contract"></i>
<p>
<span href="#" class="fas fa-plus-circle right ajax-modal" data-modal-url="/admin/modals/contract_template/contract_template_add.php" data-modal-size="lg"></span>
@@ -114,31 +140,19 @@
</li>
-->
<li class="nav-item">
<a href="/admin/project_template.php" class="nav-link <?php echo (in_array(basename($_SERVER['PHP_SELF']), ['project_template.php', 'project_template_details.php']) ? 'active' : ''); ?>">
<i class="nav-icon fas fa-project-diagram"></i>
<p>Project Templates</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/ticket_template.php" class="nav-link <?php echo (in_array(basename($_SERVER['PHP_SELF']), ['ticket_template.php', 'ticket_template_details.php']) ? 'active' : ''); ?>">
<i class="nav-icon fas fa-life-ring"></i>
<p>Ticket Templates</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/vendor_template.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'vendor_template.php' ? 'active' : ''); ?>">
<a href="/admin/vendor_templates.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'vendor_templates.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-building"></i>
<p>Vendor Templates</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/software_template.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'software_template.php' ? 'active' : ''); ?>">
<i class="nav-icon fas fa-rocket"></i>
<a href="/admin/software_templates.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'software_templates.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-box-open"></i>
<p>License Templates</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/document_template.php" class="nav-link <?php echo (in_array(basename($_SERVER['PHP_SELF']), ['document_template.php', 'document_template_details.php']) ? 'active' : ''); ?>">
<a href="/admin/document_templates.php" class="nav-link <?= (in_array(basename($_SERVER['PHP_SELF']), ['document_templates.php', 'document_template.php']) ? 'active' : '') ?>">
<i class="nav-icon fas fa-file-alt"></i>
<p>Document Templates</p>
</a>
@@ -148,44 +162,56 @@
<li class="nav-header">MAINTENANCE</li>
<li class="nav-item">
<a href="/admin/mail_queue.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'mail_queue.php' ? 'active' : ''); ?>">
<i class="nav-icon fas fa-mail-bulk"></i>
<a href="/admin/cron.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'cron.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-clock"></i>
<p>Cron</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/mail_queue.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'mail_queue.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-inbox"></i>
<p>Mail Queue</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/audit_log.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'audit_log.php' ? 'active' : ''); ?>">
<a href="/admin/audit_logs.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'audit_logs.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-history"></i>
<p>Audit Logs</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/app_log.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'app_log.php' ? 'active' : ''); ?>">
<i class="nav-icon fas fa-history"></i>
<a href="/admin/app_logs.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'app_logs.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-clipboard-list"></i>
<p>App Logs</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/backup.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'backup.php' ? 'active' : ''); ?>">
<a href="/admin/backup.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'backup.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-cloud-upload-alt"></i>
<p>Backup</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/debug.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'debug.php' ? 'active' : ''); ?>">
<a href="/admin/debug.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'debug.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-bug"></i>
<p>Debug</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/update.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'update.php' ? 'active' : ''); ?>">
<a href="/admin/update.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'update.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-download"></i>
<p>Update</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/starter_content.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'starter_content.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-seedling"></i>
<p>Starter Content</p>
</a>
</li>
<!-- SETTINGS Section -->
<li class="nav-item has-treeview mt-2 <?php echo (in_array(basename($_SERVER['PHP_SELF']), ['settings_company.php', 'settings_localization.php', 'settings_theme.php', 'settings_security.php', 'settings_mail.php', 'settings_notification.php', 'settings_default.php', 'settings_invoice.php', 'settings_quote.php', 'settings_online_payment.php', 'settings_online_payment_clients.php', 'settings_project.php', 'settings_ticket.php', 'settings_ai.php', 'identity_provider.php', 'settings_telemetry.php', 'settings_module.php']) ? 'menu-open' : ''); ?>">
<li class="nav-item has-treeview mt-2 <?= (in_array(basename($_SERVER['PHP_SELF']), ['settings_company.php', 'settings_localization.php', 'settings_theme.php', 'settings_security.php', 'settings_mail.php', 'settings_notification.php', 'settings_default.php', 'settings_invoice.php', 'settings_quote.php', 'settings_online_payment.php', 'settings_online_payment_clients.php', 'settings_project.php', 'settings_ticket.php', 'settings_ai.php', 'identity_providers.php', 'settings_telemetry.php', 'settings_module.php']) ? 'menu-open' : '') ?>">
<a href="#" class="nav-link">
<p>
SETTINGS
@@ -194,70 +220,70 @@
</a>
<ul class="nav nav-treeview">
<li class="nav-item">
<a href="/admin/settings_company.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'settings_company.php' ? 'active' : ''); ?>">
<a href="/admin/settings_company.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'settings_company.php' ? 'active' : '') ?>">
<i class="nav-icon fa fa-briefcase"></i>
<p>Company Details</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/settings_localization.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'settings_localization.php' ? 'active' : ''); ?>">
<a href="/admin/settings_localization.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'settings_localization.php' ? 'active' : '') ?>">
<i class="nav-icon fa fa-globe"></i>
<p>Localization</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/settings_theme.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'settings_theme.php' ? 'active' : ''); ?>">
<a href="/admin/settings_theme.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'settings_theme.php' ? 'active' : '') ?>">
<i class="nav-icon fa fa-paint-brush"></i>
<p>Theme</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/settings_security.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'settings_security.php' ? 'active' : ''); ?>">
<a href="/admin/settings_security.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'settings_security.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-shield-alt"></i>
<p>Security</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/settings_mail.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'settings_mail.php' ? 'active' : ''); ?>">
<a href="/admin/settings_mail.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'settings_mail.php' ? 'active' : '') ?>">
<i class="nav-icon far fa-envelope"></i>
<p>Mail</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/settings_notification.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'settings_notification.php' ? 'active' : ''); ?>">
<a href="/admin/settings_notification.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'settings_notification.php' ? 'active' : '') ?>">
<i class="nav-icon far fa-bell"></i>
<p>Notifications</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/settings_default.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'settings_default.php' ? 'active' : ''); ?>">
<a href="/admin/settings_default.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'settings_default.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-cogs"></i>
<p>Defaults</p>
</a>
</li>
<?php if ($config_module_enable_accounting) { ?>
<li class="nav-item">
<a href="/admin/settings_invoice.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'settings_invoice.php' ? 'active' : ''); ?>">
<i class="nav-icon fas fa-file-invoice"></i>
<p>Invoice</p>
<a href="/admin/settings_quote.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'settings_quote.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-comment-dollar"></i>
<p>Quote</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/settings_quote.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'settings_quote.php' ? 'active' : ''); ?>">
<i class="nav-icon fas fa-comment-dollar"></i>
<p>Quote</p>
<a href="/admin/settings_invoice.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'settings_invoice.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-file-invoice"></i>
<p>Invoice</p>
</a>
</li>
<?php } ?>
<?php if ($config_module_enable_ticketing) { ?>
<li class="nav-item">
<a href="/admin/settings_project.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'settings_project.php' ? 'active' : ''); ?>">
<a href="/admin/settings_project.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'settings_project.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-project-diagram"></i>
<p>Project</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/settings_ticket.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'settings_ticket.php' ? 'active' : ''); ?>">
<a href="/admin/settings_ticket.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'settings_ticket.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-life-ring"></i>
<p>Ticket</p>
</a>
@@ -266,20 +292,20 @@
<!-- Currently the only integration is the client portal SSO -->
<?php if ($config_client_portal_enable) { ?>
<li class="nav-item">
<a href="/admin/identity_provider.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'identity_provider.php' ? 'active' : ''); ?>">
<a href="/admin/identity_providers.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'identity_providers.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-fingerprint"></i>
<p>Identity Provider</p>
</a>
</li>
<?php } ?>
<li class="nav-item">
<a href="/admin/settings_telemetry.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'settings_telemetry.php' ? 'active' : ''); ?>">
<a href="/admin/settings_telemetry.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'settings_telemetry.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-satellite-dish"></i>
<p>Telemetry</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/settings_module.php" class="nav-link <?php echo (basename($_SERVER['PHP_SELF']) == 'settings_module.php' ? 'active' : ''); ?>">
<a href="/admin/settings_module.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'settings_module.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-cube"></i>
<p>Modules</p>
</a>
@@ -288,15 +314,15 @@
</li>
<?php
$sql_custom_links = mysqli_query($mysqli, "SELECT * FROM custom_links
$sql_custom_links = mysqli_query($mysqli, "SELECT custom_link_icon, custom_link_name, custom_link_new_tab, custom_link_uri FROM custom_links
WHERE custom_link_location = 4 AND custom_link_archived_at IS NULL
ORDER BY custom_link_order ASC, custom_link_name ASC"
);
while ($row = mysqli_fetch_assoc($sql_custom_links)) {
$custom_link_name = nullable_htmlentities($row['custom_link_name']);
$custom_link_uri = sanitize_url($row['custom_link_uri']);
$custom_link_icon = nullable_htmlentities($row['custom_link_icon']);
$custom_link_name = escapeHtml($row['custom_link_name']);
$custom_link_uri = escapeUrl($row['custom_link_uri']);
$custom_link_icon = escapeHtml($row['custom_link_icon']);
$custom_link_new_tab = intval($row['custom_link_new_tab']);
if ($custom_link_new_tab == 1) {
$target = "target='_blank' rel='noopener noreferrer'";
@@ -307,9 +333,9 @@
?>
<li class="nav-item">
<a href="<?php echo $custom_link_uri; ?>" <?php echo $target; ?> class="nav-link <?php if (basename($_SERVER["PHP_SELF"]) == basename($custom_link_uri)) { echo "active"; } ?>">
<i class="fas fa-<?php echo $custom_link_icon; ?> nav-icon"></i>
<p><?php echo $custom_link_name; ?></p>
<a href="<?= $custom_link_uri ?>" <?= $target ?> class="nav-link <?php if (basename($_SERVER["PHP_SELF"]) == basename($custom_link_uri)) { echo "active"; } ?>">
<i class="fas fa-<?= $custom_link_icon ?> nav-icon"></i>
<p><?= $custom_link_name ?></p>
<i class="fas fa-angle-right nav-icon float-right"></i>
</a>
</li>
@@ -322,4 +348,4 @@
<div class="mb-3"></div>
</div>
<!-- /.sidebar -->
</aside>
</aside>

View File

@@ -8,7 +8,8 @@ require_once "includes/inc_all_admin.php";
$sql = mysqli_query(
$mysqli,
"SELECT SQL_CALC_FOUND_ROWS * FROM email_queue
"SELECT SQL_CALC_FOUND_ROWS email_attempts, email_failed_at, email_from, email_from_name, email_id, email_queued_at,
email_recipient, email_recipient_name, email_sent_at, email_status, email_subject FROM email_queue
WHERE (email_id LIKE '%$q%' OR email_from LIKE '%$q%' OR email_from_name LIKE '%$q%' OR email_recipient LIKE '%$q%' OR email_recipient_name LIKE '%$q%' OR email_subject LIKE '%$q%')
AND DATE(email_queued_at) BETWEEN '$dtf' AND '$dtt'
ORDER BY $sort $order LIMIT $record_from, $record_to"
@@ -20,14 +21,14 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<div class="card card-dark">
<div class="card-header py-3">
<h3 class="card-title"><i class="fas fa-fw fa-mail-bulk mr-2"></i>Email Queue</h3>
<h3 class="card-title"><i class="fas fa-fw fa-inbox mr-2"></i>Email Queue</h3>
</div>
<div class="card-body">
<form class="mb-4" autocomplete="off">
<div class="row">
<div class="col-sm-4">
<div class="input-group">
<input type="search" class="form-control" name="q" value="<?php if (isset($q)) { echo stripslashes(nullable_htmlentities($q)); } ?>" placeholder="Search mail queue">
<input type="search" class="form-control" name="q" value="<?php if (isset($q)) { echo stripslashes(escapeHtml($q)); } ?>" placeholder="Search mail queue">
<div class="input-group-append">
<button class="btn btn-secondary" type="button" data-toggle="collapse" data-target="#advancedFilter"><i class="fas fa-filter"></i></button>
<button class="btn btn-primary"><i class="fa fa-search"></i></button>
@@ -59,9 +60,9 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<div class="form-group">
<label>Date range</label>
<input type="text" id="dateFilter" class="form-control" autocomplete="off">
<input type="hidden" name="canned_date" id="canned_date" value="<?php echo nullable_htmlentities($_GET['canned_date']) ?? ''; ?>">
<input type="hidden" name="dtf" id="dtf" value="<?php echo nullable_htmlentities($dtf ?? ''); ?>">
<input type="hidden" name="dtt" id="dtt" value="<?php echo nullable_htmlentities($dtt ?? ''); ?>">
<input type="hidden" name="canned_date" id="canned_date" value="<?= escapeHtml($_GET['canned_date']) ?? '' ?>">
<input type="hidden" name="dtf" id="dtf" value="<?= escapeHtml($dtf ?? '') ?>">
<input type="hidden" name="dtt" id="dtt" value="<?= escapeHtml($dtt ?? '') ?>">
</div>
</div>
</div>
@@ -69,7 +70,7 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
</form>
<hr>
<form id="bulkActions" action="post.php" method="post">
<input type="hidden" name="csrf_token" value="<?php echo $_SESSION['csrf_token'] ?>">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="table-responsive-sm">
<table class="table table-sm table-striped table-borderless table-hover">
@@ -81,32 +82,32 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
</div>
</td>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=email_queued_at&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=email_queued_at&order=<?= $disp ?>">
Queued <?php if ($sort == 'email_queued_at') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=email_from&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=email_from&order=<?= $disp ?>">
From <?php if ($sort == 'email_from') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=email_recipient&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=email_recipient&order=<?= $disp ?>">
To <?php if ($sort == 'email_recipient') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=email_subject&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=email_subject&order=<?= $disp ?>">
Subject <?php if ($sort == 'email_subject') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=email_status&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=email_status&order=<?= $disp ?>">
Status <?php if ($sort == 'email_status') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?php echo $url_query_strings_sort; ?>&sort=email_attempts&order=<?php echo $disp; ?>">
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=email_attempts&order=<?= $disp ?>">
Attempts <?php if ($sort == 'email_attempts') { echo $order_icon; } ?>
</a>
</th>
@@ -118,15 +119,15 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
while ($row = mysqli_fetch_assoc($sql)) {
$email_id = intval($row['email_id']);
$email_from = nullable_htmlentities($row['email_from']);
$email_from_name = nullable_htmlentities($row['email_from_name']);
$email_recipient = nullable_htmlentities($row['email_recipient']);
$email_recipient_name = nullable_htmlentities($row['email_recipient_name']);
$email_subject = nullable_htmlentities($row['email_subject']);
$email_from = escapeHtml($row['email_from']);
$email_from_name = escapeHtml($row['email_from_name']);
$email_recipient = escapeHtml($row['email_recipient']);
$email_recipient_name = escapeHtml($row['email_recipient_name']);
$email_subject = escapeHtml($row['email_subject']);
$email_attempts = intval($row['email_attempts']);
$email_queued_at = nullable_htmlentities($row['email_queued_at']);
$email_failed_at = nullable_htmlentities($row['email_failed_at']);
$email_sent_at = nullable_htmlentities($row['email_sent_at']);
$email_queued_at = escapeHtml($row['email_queued_at']);
$email_failed_at = escapeHtml($row['email_failed_at']);
$email_sent_at = escapeHtml($row['email_sent_at']);
$email_status = intval($row['email_status']);
if ($email_status == 0) {
$email_status_display = "<div class='text-primary'>Queued</div>";
@@ -144,16 +145,16 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<td class="pr-0 bg-light">
<?php if ($email_status !== 3) { ?>
<div class="form-check">
<input class="form-check-input bulk-select" type="checkbox" name="email_ids[]" value="<?php echo $email_id ?>">
<input class="form-check-input bulk-select" type="checkbox" name="email_ids[]" value="<?= $email_id ?>">
</div>
<?php } ?>
</td>
<td class="text-monospace"><?php echo $email_queued_at; ?></td>
<td><?php echo "$email_from<br><small class='text-secondary'>$email_from_name</small>"?></td>
<td><?php echo "$email_recipient<br><small class='text-secondary'>$email_recipient_name</small>"?></td>
<td><?php echo $email_subject; ?></td>
<td><?php echo $email_status_display; ?></td>
<td><?php echo $email_attempts; ?></td>
<td class="text-monospace"><?= $email_queued_at ?></td>
<td><?= "$email_from<br><small class='text-secondary'>$email_from_name</small>" ?></td>
<td><?= "$email_recipient<br><small class='text-secondary'>$email_recipient_name</small>" ?></td>
<td><?= $email_subject ?></td>
<td><?= $email_status_display ?></td>
<td><?= $email_attempts ?></td>
<td class="text-center">
<a class="btn btn-sm btn-secondary ajax-modal" href="#"
data-modal-size="lg"
@@ -163,12 +164,12 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<!-- Show force resend if all retries have failed -->
<?php if ($email_status == 2 && $email_attempts > 3) { ?>
<a class="btn btn-sm btn-success" href="post.php?send_failed_mail=<?php echo $email_id; ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>"><i class="fas fa-fw fa-paper-plane"></i></a>
<a class="btn btn-sm btn-success" href="post.php?send_failed_mail=<?= $email_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>"><i class="fas fa-fw fa-paper-plane"></i></a>
<?php } ?>
<!-- Allow cancelling a message if it hasn't yet been picked up (e.g. stuck/bugged) -->
<?php if ($email_status !== 3) { ?>
<a class="btn btn-sm btn-danger confirm-link" href="post.php?cancel_mail=<?php echo $email_id; ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>"><i class="fas fa-fw fa-trash"></i></a>
<a class="btn btn-sm btn-danger confirm-link" href="post.php?cancel_mail=<?= $email_id ?>&csrf_token=<?= $_SESSION['csrf_token'] ?>"><i class="fas fa-fw fa-trash"></i></a>
<?php } ?>
</td>

View File

@@ -1,6 +1,6 @@
<?php
require_once '../../../includes/modal_header.php';
require_once '../../includes/modal_header.php';
ob_start();
@@ -13,7 +13,7 @@ ob_start();
</button>
</div>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?php echo $_SESSION['csrf_token'] ?>">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="modal-body">
@@ -26,13 +26,13 @@ ob_start();
<select class="form-control select2" name="provider" required>
<option value="">- Select an AI Provider -</option>
<?php
$sql_ai_providers = mysqli_query($mysqli, "SELECT * FROM ai_providers");
$sql_ai_providers = mysqli_query($mysqli, "SELECT ai_provider_id, ai_provider_name FROM ai_providers");
while ($row = mysqli_fetch_assoc($sql_ai_providers)) {
$ai_provider_id = intval($row['ai_provider_id']);
$ai_provider_name = nullable_htmlentities($row['ai_provider_name']);
$ai_provider_name = escapeHtml($row['ai_provider_name']);
?>
<option value="<?php echo $ai_provider_id; ?>"><?php echo $ai_provider_name; ?></option>
<option value="<?= $ai_provider_id ?>"><?= $ai_provider_name ?></option>
<?php } ?>
</select>
</div>
@@ -44,7 +44,7 @@ ob_start();
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-robot"></i></span>
</div>
<input type="text" class="form-control" name="model" placeholder="ex gpt-4">
<input type="text" class="form-control" name="model" placeholder="ex gpt-4" maxlength="200">
</div>
</div>
@@ -62,6 +62,17 @@ ob_start();
</div>
</div>
<div class="form-group">
<label>Temperature</label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-thermometer-half"></i></span>
</div>
<input type="number" class="form-control" name="temperature" step="0.1" min="0" max="2" value="" placeholder="Provider default">
</div>
<small class="form-text text-muted">Optional. Leave blank to let the provider use its default - some newer models reject every other value.</small>
</div>
<div class="form-group">
<textarea class="form-control" rows="8" name="prompt" placeholder="Enter a model prompt:"></textarea>
</div>

View File

@@ -1,31 +1,32 @@
<?php
require_once '../../../includes/modal_header.php';
require_once '../../includes/modal_header.php';
$model_id = intval($_GET['id']);
$sql = mysqli_query($mysqli, "SELECT * FROM ai_models WHERE ai_model_id = $model_id LIMIT 1");
$sql = mysqli_query($mysqli, "SELECT ai_model_ai_provider_id, ai_model_id, ai_model_name, ai_model_prompt, ai_model_use_case, ai_model_temperature FROM ai_models WHERE ai_model_id = $model_id LIMIT 1");
$row = mysqli_fetch_assoc($sql);
$ai_model_ai_provider_id = intval($row['ai_model_ai_provider_id']);
$model_id = intval($row['ai_model_id']);
$model_name = nullable_htmlentities($row['ai_model_name']);
$use_case = nullable_htmlentities($row['ai_model_use_case']);
$prompt = nullable_htmlentities($row['ai_model_prompt']);
$model_name = escapeHtml($row['ai_model_name']);
$use_case = escapeHtml($row['ai_model_use_case']);
$temperature = escapeHtml($row['ai_model_temperature']);
$prompt = escapeHtml($row['ai_model_prompt']);
// Generate the HTML form content using output buffering.
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fa fa-fw fa-robot mr-2"></i>Editing: <strong><?php echo $model_name; ?></strong></h5>
<h5 class="modal-title"><i class="fa fa-fw fa-robot mr-2"></i>Editing: <strong><?= $model_name ?></strong></h5>
<button type="button" class="close text-light" data-dismiss="modal">
<span>&times;</span>
</button>
</div>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?php echo $_SESSION['csrf_token'] ?>">
<input type="hidden" name="model_id" value="<?php echo $model_id; ?>">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="model_id" value="<?= $model_id ?>">
<div class="modal-body">
@@ -38,13 +39,13 @@ ob_start();
<select class="form-control select2" name="provider" required>
<option value="">- Select an AI Provider -</option>
<?php
$sql_ai_providers = mysqli_query($mysqli, "SELECT * FROM ai_providers");
$sql_ai_providers = mysqli_query($mysqli, "SELECT ai_provider_id, ai_provider_name FROM ai_providers");
while ($row = mysqli_fetch_assoc($sql_ai_providers)) {
$ai_provider_id = intval($row['ai_provider_id']);
$ai_provider_name = nullable_htmlentities($row['ai_provider_name']);
$ai_provider_name = escapeHtml($row['ai_provider_name']);
?>
<option <?php if ($ai_provider_id = $ai_model_ai_provider_id) { echo "selected"; } ?> value="<?php echo $ai_provider_id; ?>"><?php echo $ai_provider_name; ?></option>
<option <?php if ($ai_provider_id = $ai_model_ai_provider_id) { echo "selected"; } ?> value="<?= $ai_provider_id ?>"><?= $ai_provider_name ?></option>
<?php } ?>
</select>
</div>
@@ -56,7 +57,7 @@ ob_start();
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-robot"></i></span>
</div>
<input type="text" class="form-control" name="model" value="<?php echo $model_name; ?>" placeholder="ex gpt-4">
<input type="text" class="form-control" name="model" value="<?= $model_name ?>" placeholder="ex gpt-4" maxlength="200">
</div>
</div>
@@ -75,7 +76,17 @@ ob_start();
</div>
<div class="form-group">
<textarea class="form-control" rows="8" name="prompt" placeholder="Enter a model prompt:"><?php echo $prompt; ?></textarea>
<label>Temperature</label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-thermometer-half"></i></span>
</div>
<input type="number" class="form-control" name="temperature" step="0.1" min="0" max="2" value="<?= $temperature ?>" placeholder="Provider default">
</div>
<small class="form-text text-muted">Optional. Leave blank to let the provider use its default - some newer models reject every other value.</small>
</div>
<div class="form-group">
<textarea class="form-control" rows="8" name="prompt" placeholder="Enter a model prompt:"><?= $prompt ?></textarea>
</div>
</div>

View File

@@ -1,6 +1,6 @@
<?php
require_once '../../../includes/modal_header.php';
require_once '../../includes/modal_header.php';
ob_start();
@@ -13,7 +13,7 @@ ob_start();
</button>
</div>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?php echo $_SESSION['csrf_token'] ?>">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<div class="modal-body">
@@ -23,7 +23,7 @@ ob_start();
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-robot"></i></span>
</div>
<input type="text" class="form-control" name="provider" placeholder="ex OpenAI">
<input type="text" class="form-control" name="provider" placeholder="ex OpenAI" maxlength="200">
</div>
</div>
@@ -33,7 +33,7 @@ ob_start();
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-globe"></i></span>
</div>
<input type="url" class="form-control" name="url" placeholder="ex https://ai.company.ext/api">
<input type="url" class="form-control" name="url" placeholder="ex https://ai.company.ext/api" maxlength="200">
</div>
</div>
@@ -43,7 +43,7 @@ ob_start();
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-key"></i></span>
</div>
<input type="text" class="form-control" name="api_key" placeholder="Enter API key here">
<input type="text" class="form-control" name="api_key" placeholder="Enter API key here" maxlength="200">
</div>
</div>

View File

@@ -1,29 +1,29 @@
<?php
require_once '../../../includes/modal_header.php';
require_once '../../includes/modal_header.php';
$provider_id = intval($_GET['id']);
$sql = mysqli_query($mysqli, "SELECT * FROM ai_providers WHERE ai_provider_id = $provider_id LIMIT 1");
$sql = mysqli_query($mysqli, "SELECT ai_provider_api_key, ai_provider_api_url, ai_provider_name FROM ai_providers WHERE ai_provider_id = $provider_id LIMIT 1");
$row = mysqli_fetch_assoc($sql);
$provider_name = nullable_htmlentities($row['ai_provider_name']);
$url = nullable_htmlentities($row['ai_provider_api_url']);
$key = nullable_htmlentities($row['ai_provider_api_key']);
$provider_name = escapeHtml($row['ai_provider_name']);
$url = escapeHtml($row['ai_provider_api_url']);
$key = escapeHtml($row['ai_provider_api_key']);
// Generate the HTML form content using output buffering.
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fa fa-fw fa-robot mr-2"></i>Editing: <strong><?php echo $provider_name; ?></strong></h5>
<h5 class="modal-title"><i class="fa fa-fw fa-robot mr-2"></i>Editing: <strong><?= $provider_name ?></strong></h5>
<button type="button" class="close text-light" data-dismiss="modal">
<span>&times;</span>
</button>
</div>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?php echo $_SESSION['csrf_token'] ?>">
<input type="hidden" name="provider_id" value="<?php echo $provider_id; ?>">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="provider_id" value="<?= $provider_id ?>">
<div class="modal-body">
@@ -33,7 +33,7 @@ ob_start();
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-robot"></i></span>
</div>
<input type="text" class="form-control" name="provider" value="<?php echo $provider_name; ?>" placeholder="ex OpenAI">
<input type="text" class="form-control" name="provider" value="<?= $provider_name ?>" placeholder="ex OpenAI" maxlength="200">
</div>
</div>
@@ -43,7 +43,7 @@ ob_start();
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-globe"></i></span>
</div>
<input type="url" class="form-control" name="url" value="<?php echo $url; ?>" placeholder="ex https://ai.company.ext/api">
<input type="url" class="form-control" name="url" value="<?= $url ?>" placeholder="ex https://ai.company.ext/api" maxlength="200">
</div>
</div>
@@ -53,7 +53,7 @@ ob_start();
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-key"></i></span>
</div>
<input type="text" class="form-control" name="api_key" value="<?php echo $key; ?>" placeholder="Enter API key here">
<input type="text" class="form-control" name="api_key" value="<?= $key ?>" placeholder="Enter API key here" maxlength="200">
</div>
</div>

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