Compare commits
283 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b2d8e4d03 | |||
| 7fb60a365e | |||
| fb3b607bd1 | |||
| dad7c24bff | |||
| 9e9d8a33e3 | |||
| dfae1d4648 | |||
| ac82300675 | |||
| 31510cfe0f | |||
| 5fce716489 | |||
| c90bdc8ac8 | |||
| b6df647921 | |||
| e3a115fd02 | |||
| 46285b8abc | |||
| d38cc1bfbe | |||
| 56007f7479 | |||
| 7dba849c12 | |||
| 3e9f5e82db | |||
| f42ee8070f | |||
| 3b0b7621e0 | |||
| e3ebc766e5 | |||
| 2d6b2b8058 | |||
| 3c7b3475e4 | |||
| 55c2d5c596 | |||
| 0f71ef9935 | |||
| e2eabad413 | |||
| 9a8940b9d0 | |||
| f7321863e6 | |||
| d21691a548 | |||
| b385e177ec | |||
| 60f23051a9 | |||
| f9faca55bb | |||
| 1b75ad14fb | |||
| 1a85d20b8e | |||
| c442e2d47f | |||
| a62123236d | |||
| 47b70b0ee8 | |||
| b841037130 | |||
| 6b89a14a47 | |||
| 63092ac070 | |||
| d0c889a594 | |||
| f93cebe2d9 | |||
| ab0edd1325 | |||
| a3fbad19c9 | |||
| d295d64f85 | |||
| d6603d07f2 | |||
| d443caf059 | |||
| e9a033d4ef | |||
| 3a516c5424 | |||
| 74d1770cd6 | |||
| ddf1d236eb | |||
| ccd53dae00 | |||
| cd83464c5d | |||
| 47c631ad4f | |||
| 50e6ee749e | |||
| 846417580e | |||
| 8e8a63fa7d | |||
| 424f3f9f95 | |||
| 8cb7cc0356 | |||
| 5c1ec6882e | |||
| 355b173070 | |||
| 603ba18067 | |||
| dd98bfbd49 | |||
| 55a3d2945c | |||
| 727c5171ff | |||
| 444dc4bf26 | |||
| 09292119e6 | |||
| 499060795e | |||
| fe9c6b3ee0 | |||
| 570b1749da | |||
| cc509874e7 | |||
| 6e1ae01cac | |||
| c3ab5c5716 | |||
| 538baadd57 | |||
| fbda618fbb | |||
| 01f2dac2d6 | |||
| 4433bad2ce | |||
| 1761f41943 | |||
| 2378e56268 | |||
| 025963a78f | |||
| c6037a9ccc | |||
| 6c491c1baa | |||
| 6eae9ef816 | |||
| bc88ba3612 | |||
| 5e04478586 | |||
| 9494df2bf9 | |||
| ac05b212b2 | |||
| df6c4de196 | |||
| 2ccf4f2261 | |||
| dcbe6fb383 | |||
| 914c33ecf3 | |||
| d588590989 | |||
| b7b6884bb0 | |||
| 54887ffa24 | |||
| 613886068d | |||
| 847d6b2656 | |||
| c2cd923d32 | |||
| 67a7d769f0 | |||
| 84b104a501 | |||
| ff109a710c | |||
| 1ab374531c | |||
| bfe00ea0f6 | |||
| 04b019a8e1 | |||
| c15defc09b | |||
| 3c29c6ee6f | |||
| 9916daa904 | |||
| 6727aeea29 | |||
| 0d8edc9d34 | |||
| fca4896e0d | |||
| c0dfbdbc26 | |||
| 85afec64ac | |||
| ec92445a0f | |||
| 0eab5d40e6 | |||
| 3cfe46050b | |||
| e71f35c041 | |||
| 6102985f92 | |||
| e91709798b | |||
| 4150e1ced3 | |||
| cfdc9e0f37 | |||
| 55c6fc81db | |||
| fdc6d3d463 | |||
| 72d5061867 | |||
| 1d721eecb4 | |||
| cfb88d9c88 | |||
| a89095cbcc | |||
| ade1a70214 | |||
| 0acf5e84c3 | |||
| c8181e8076 | |||
| cc3f667d4c | |||
| 2fdd42b45b | |||
| 277daf6f00 | |||
| f709e98bd3 | |||
| e6b6a2a88c | |||
| f983269f93 | |||
| 7be283423a | |||
| 2e450dc01d | |||
| f0abadfc57 | |||
| d33f761a55 | |||
| cfbef029cb | |||
| 5242d42fa7 | |||
| d8e6dcf7fa | |||
| 6b76496640 | |||
| b40c404828 | |||
| 18bf1fde0e | |||
| 87f878ee6b | |||
| 82aa4bf5de | |||
| e2c23d0405 | |||
| 170bd86aa6 | |||
| 3bb4792635 | |||
| b42597c927 | |||
| e721b33911 | |||
| d7775e62ec | |||
| 51f6991f9d | |||
| 9bdeaf7731 | |||
| 79c2d2b513 | |||
| 1989bcb8c8 | |||
| 0a2214bfaf | |||
| e7d01ef576 | |||
| a403e49537 | |||
| 06b7a8f59b | |||
| 9f1a375e5a | |||
| 84cc023bc4 | |||
| 164c2d231a | |||
| ce95e555d5 | |||
| f45ec9b0f7 | |||
| 5a41ebf180 | |||
| e35401d54e | |||
| 913e294f9d | |||
| 28aa9e33ea | |||
| 31aa7d1b81 | |||
| 7695c6134c | |||
| 11f75fd823 | |||
| e179709fc3 | |||
| b03a9cfc8c | |||
| d44a530018 | |||
| 3c3b9d0a61 | |||
| 1046537429 | |||
| d8220da1e0 | |||
| 021c01b3d4 | |||
| 22cab10d5d | |||
| f0d7b9aa61 | |||
| 3493ed78f8 | |||
| 90c5b3ff71 | |||
| 84bea80abd | |||
| 2f9af856dc | |||
| 27075a62ee | |||
| dd8833ee2f | |||
| ab3e77a9ba | |||
| 68ff89b48c | |||
| 328c103460 | |||
| 21ef9154e9 | |||
| 4ecd72bc04 | |||
| 368ad9b48e | |||
| 3497c4cb47 | |||
| e756f8e0bb | |||
| fea7575ac8 | |||
| 6fbba3939f | |||
| f3c15e2582 | |||
| 51fa5a8a3c | |||
| 4a838b68ca | |||
| 5545328e53 | |||
| 8bb43c14db | |||
| 92544d60ce | |||
| 89a685a502 | |||
| d204756cfe | |||
| a34ca51223 | |||
| f59913910f | |||
| 13f0fab138 | |||
| bcc163bc77 | |||
| 15063838bd | |||
| 019eaf8980 | |||
| e8b2f670b9 | |||
| b0ffc2cdc2 | |||
| cbce4b5fac | |||
| 23da1ef421 | |||
| 79706f790d | |||
| 99a96544cf | |||
| df367b9914 | |||
| 44221b858c | |||
| 712e9b70ce | |||
| 7a6e7ea2b0 | |||
| 2657e86d24 | |||
| 73162d9a9b | |||
| 2ba3d40b3b | |||
| 3ceea77fe1 | |||
| 651c8115f6 | |||
| 6dff92db45 | |||
| a8738fdf57 | |||
| 1c1eb19876 | |||
| 9b40a714ed | |||
| ed9c2a39d1 | |||
| 5b2a2c271e | |||
| 44f2c21f2d | |||
| 7575d6a277 | |||
| c3f7593f3c | |||
| 37be81b3e2 | |||
| 8a8b1b0258 | |||
| d2a8c73e2c | |||
| 1101558fca | |||
| 55209e0b05 | |||
| 674a427edb | |||
| fa40010287 | |||
| a08390a500 | |||
| 80a61fcd31 | |||
| 2be85b6f58 | |||
| b1013392e6 | |||
| 8b89114607 | |||
| ee796dce91 | |||
| 98db586bcf | |||
| 7ecb593c0f | |||
| d073add6a6 | |||
| efa1b81a62 | |||
| 7465fb6fc4 | |||
| ee317d6662 | |||
| 11a593a7dd | |||
| 6e569c8918 | |||
| 9360e38fbb | |||
| 5c22526c08 | |||
| 6d03f9c89b | |||
| 380b0e1adf | |||
| b8a987e4c6 | |||
| e86a5de3fd | |||
| c32e9c871b | |||
| 8b4ef2a7f5 | |||
| 2c35ccc199 | |||
| 0046721fde | |||
| 08d6808bc3 | |||
| 7462d7c509 | |||
| 2ce4a14201 | |||
| 92f936e1be | |||
| ebf318f8af | |||
| 10d5075f2d | |||
| 7dffd8ed35 | |||
| 591fad52cc | |||
| bc6a5cecf8 | |||
| be505b7312 | |||
| 8c7211d311 | |||
| 496e8d6c21 | |||
| ee69b9094b | |||
| bb4b1400f2 | |||
| 1b66663307 | |||
| 63dc2d6314 | |||
| d86a60c609 | |||
| 998b85e907 |
@@ -0,0 +1,39 @@
|
|||||||
|
# Tinker Tickets Environment Configuration
|
||||||
|
# Copy this file to .env and fill in your values
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DB_HOST=10.10.10.50
|
||||||
|
DB_USER=tinkertickets
|
||||||
|
DB_PASS=your_password_here
|
||||||
|
DB_NAME=ticketing_system
|
||||||
|
|
||||||
|
# Matrix Webhook (optional - for notifications via matrix-hookshot)
|
||||||
|
# Set to your hookshot generic webhook URL, e.g.:
|
||||||
|
# https://matrix.lotusguild.org/webhook/<uuid>
|
||||||
|
MATRIX_WEBHOOK_URL=
|
||||||
|
|
||||||
|
# Matrix users to @mention on every new ticket (comma-separated Matrix user IDs)
|
||||||
|
# e.g. @jared:matrix.lotusguild.org,@alice:matrix.lotusguild.org
|
||||||
|
MATRIX_NOTIFY_USERS=
|
||||||
|
|
||||||
|
# Application Domain (required for Matrix webhook ticket links)
|
||||||
|
# Set this to your public domain (e.g., t.lotusguild.org)
|
||||||
|
APP_DOMAIN=
|
||||||
|
|
||||||
|
# Allowed Hosts for HTTP_HOST validation (comma-separated)
|
||||||
|
# Include all domains that can access this application
|
||||||
|
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
|
|
||||||
|
# Timezone (default: America/New_York)
|
||||||
|
TIMEZONE=America/New_York
|
||||||
|
|
||||||
|
# LDAP / lldap (for user avatar lookups)
|
||||||
|
LDAP_ENABLED=true
|
||||||
|
LDAP_HOST=10.10.10.39
|
||||||
|
LDAP_PORT=3890
|
||||||
|
LDAP_BIND_DN=uid=tinker-tickets,ou=people,dc=example,dc=com
|
||||||
|
LDAP_BIND_PW=
|
||||||
|
LDAP_BASE_DN=dc=example,dc=com
|
||||||
|
LDAP_USER_BASE=ou=people,dc=example,dc=com
|
||||||
|
# How long to cache avatar images locally (seconds, default 3600)
|
||||||
|
AVATAR_CACHE_TTL=3600
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es2021": true
|
||||||
|
},
|
||||||
|
"extends": "eslint:recommended",
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 2021,
|
||||||
|
"sourceType": "script"
|
||||||
|
},
|
||||||
|
"globals": {
|
||||||
|
"lt": "readonly",
|
||||||
|
"module": "writable"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"no-undef": "off",
|
||||||
|
"no-unused-vars": "warn",
|
||||||
|
"no-empty": "warn",
|
||||||
|
"no-inner-declarations": "warn",
|
||||||
|
"no-useless-escape": "warn",
|
||||||
|
"no-regex-spaces": "warn",
|
||||||
|
"semi": ["error", "always"],
|
||||||
|
"eqeqeq": "warn"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
name: Lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["**"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
php-lint:
|
||||||
|
name: PHP (phpcs PSR-12)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install PHP and phpcs
|
||||||
|
run: |
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq php-cli php-xml
|
||||||
|
curl -sL https://squizlabs.github.io/PHP_CodeSniffer/phpcs.phar -o /usr/local/bin/phpcs
|
||||||
|
chmod +x /usr/local/bin/phpcs
|
||||||
|
|
||||||
|
- name: Run phpcs
|
||||||
|
run: phpcs --standard=.phpcs.xml .
|
||||||
|
|
||||||
|
js-lint:
|
||||||
|
name: JS (eslint)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install ESLint
|
||||||
|
run: npm install --save-dev eslint@8
|
||||||
|
|
||||||
|
- name: Run ESLint
|
||||||
|
run: npx eslint assets/js/
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [php-lint, js-lint]
|
||||||
|
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/development')
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Trigger webhook
|
||||||
|
env:
|
||||||
|
WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }}
|
||||||
|
GIT_REF: ${{ github.ref }}
|
||||||
|
run: |
|
||||||
|
if [ "$GIT_REF" = "refs/heads/main" ]; then
|
||||||
|
HOOK_ID="tinker-deploy"
|
||||||
|
else
|
||||||
|
HOOK_ID="tinker-beta-deploy"
|
||||||
|
fi
|
||||||
|
PAYLOAD="{\"ref\":\"${GIT_REF}\"}"
|
||||||
|
SIG=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}')
|
||||||
|
curl -sf --connect-timeout 10 \
|
||||||
|
-X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Gitea-Signature: ${SIG}" \
|
||||||
|
-d "$PAYLOAD" \
|
||||||
|
"http://10.10.10.45:9000/hooks/${HOOK_ID}"
|
||||||
|
|
||||||
|
- name: Tag deployed commit
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
TAG="deploy-$(date -u +%Y.%m.%d)-${{ github.run_number }}"
|
||||||
|
curl -sf -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"${TAG}\",\"target\":\"${{ github.sha }}\",\"message\":\"Deployed to production\"}" \
|
||||||
|
"https://code.lotusguild.org/api/v1/repos/${{ github.repository }}/tags"
|
||||||
|
|
||||||
|
notify-failure:
|
||||||
|
name: Notify on failure
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [php-lint, js-lint]
|
||||||
|
if: failure() && github.event_name == 'push'
|
||||||
|
steps:
|
||||||
|
- name: Send Matrix alert
|
||||||
|
env:
|
||||||
|
MATRIX_WEBHOOK_URL: ${{ secrets.MATRIX_WEBHOOK_URL }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
BRANCH: ${{ github.ref_name }}
|
||||||
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$MATRIX_WEBHOOK_URL" ] || [ "$MATRIX_WEBHOOK_URL" = "CONFIGURE_ME" ]; then exit 0; fi
|
||||||
|
curl -sf -X POST "$MATRIX_WEBHOOK_URL" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"text\":\"CI FAILED: ${REPO} @ ${BRANCH} — ${RUN_URL}\"}"
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
name: Security
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["**"]
|
||||||
|
schedule:
|
||||||
|
- cron: '0 6 * * 1'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
semgrep:
|
||||||
|
name: PHP Security (semgrep)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install semgrep
|
||||||
|
run: |
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq python3 python3-pip
|
||||||
|
pip3 install semgrep
|
||||||
|
|
||||||
|
- name: Run semgrep
|
||||||
|
run: semgrep --config=p/php --config=p/owasp-top-ten --error .
|
||||||
+8
-1
@@ -1,2 +1,9 @@
|
|||||||
.env
|
.env
|
||||||
debug.log
|
debug.log
|
||||||
|
.claude
|
||||||
|
settings.local.json
|
||||||
|
|
||||||
|
# Upload files (keep folder structure, ignore actual uploads)
|
||||||
|
uploads/*
|
||||||
|
!uploads/.gitkeep
|
||||||
|
!uploads/.htaccess
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<ruleset name="TinkerTickets">
|
||||||
|
<description>PSR-12 with project-specific exclusions</description>
|
||||||
|
|
||||||
|
<file>.</file>
|
||||||
|
<exclude-pattern>*/uploads/*</exclude-pattern>
|
||||||
|
<exclude-pattern>*/migrations/*</exclude-pattern>
|
||||||
|
<exclude-pattern>*/.gitea/*</exclude-pattern>
|
||||||
|
|
||||||
|
<arg name="extensions" value="php"/>
|
||||||
|
<arg name="colors"/>
|
||||||
|
<arg value="sp"/>
|
||||||
|
|
||||||
|
<rule ref="PSR12">
|
||||||
|
<!-- Codebase does not use namespaces -->
|
||||||
|
<exclude name="PSR1.Classes.ClassDeclaration.MissingNamespace"/>
|
||||||
|
<!-- Files mix includes and class definitions by design -->
|
||||||
|
<exclude name="PSR1.Files.SideEffects.FoundWithSymbols"/>
|
||||||
|
<!-- View files contain long HTML strings — not worth wrapping -->
|
||||||
|
<exclude name="Generic.Files.LineLength"/>
|
||||||
|
</rule>
|
||||||
|
</ruleset>
|
||||||
|
|
||||||
@@ -1,727 +0,0 @@
|
|||||||
# Tinker Tickets - Project Documentation for AI Assistants
|
|
||||||
|
|
||||||
## Project Status (January 2026)
|
|
||||||
|
|
||||||
**Current Phase**: All 5 core features implemented and deployed. Ready for ANSI Art redesign.
|
|
||||||
|
|
||||||
**Recent Completion**:
|
|
||||||
- ✅ Activity Timeline (Feature 1)
|
|
||||||
- ✅ Ticket Assignment (Feature 2)
|
|
||||||
- ✅ Status Transitions with Workflows (Feature 3)
|
|
||||||
- ✅ Ticket Templates (Feature 4)
|
|
||||||
- ✅ Bulk Actions - Admin Only (Feature 5)
|
|
||||||
|
|
||||||
**Next Priority**: 🎨 ANSI Art Redesign (major visual overhaul)
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
Tinker Tickets is a feature-rich, self-hosted ticket management system built for managing data center infrastructure issues. It features SSO integration with Authelia/LLDAP, workflow management, Discord notifications, and a comprehensive web interface.
|
|
||||||
|
|
||||||
**Tech Stack:**
|
|
||||||
- Backend: PHP 7.4+ with MySQLi
|
|
||||||
- Frontend: Vanilla JavaScript, CSS3
|
|
||||||
- Database: MariaDB on separate LXC (10.10.10.50)
|
|
||||||
- Web Server: Apache on production (10.10.10.45)
|
|
||||||
- Authentication: Authelia SSO with LLDAP backend
|
|
||||||
- External Libraries: marked.js (Markdown rendering)
|
|
||||||
|
|
||||||
**Production Environment:**
|
|
||||||
- **Primary URL**: http://t.lotusguild.org
|
|
||||||
- **Web Server**: Apache at 10.10.10.45 (`/root/code/tinker_tickets`)
|
|
||||||
- **Database**: MariaDB at 10.10.10.50 (`ticketing_system` database)
|
|
||||||
- **Authentication**: Authelia provides SSO via headers
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### MVC Pattern
|
|
||||||
```
|
|
||||||
Controllers → Models → Database
|
|
||||||
↓
|
|
||||||
Views
|
|
||||||
```
|
|
||||||
|
|
||||||
### Project Structure (Updated)
|
|
||||||
```
|
|
||||||
/tinker_tickets/
|
|
||||||
├── api/ # API endpoints
|
|
||||||
│ ├── add_comment.php # POST: Add comment
|
|
||||||
│ ├── assign_ticket.php # POST: Assign ticket to user (NEW)
|
|
||||||
│ ├── bulk_operation.php # POST: Bulk operations - admin only (NEW)
|
|
||||||
│ ├── get_template.php # GET: Fetch ticket template (NEW)
|
|
||||||
│ ├── get_users.php # GET: Get user list (NEW)
|
|
||||||
│ └── update_ticket.php # POST: Update ticket (workflow validation)
|
|
||||||
├── assets/
|
|
||||||
│ ├── css/
|
|
||||||
│ │ ├── dashboard.css # Shared + dashboard + bulk actions
|
|
||||||
│ │ └── ticket.css # Ticket + timeline + dark mode fixes
|
|
||||||
│ ├── js/
|
|
||||||
│ │ ├── dashboard.js # Dashboard + hamburger + bulk actions + templates
|
|
||||||
│ │ └── ticket.js # Ticket + comments + status updates + assignment
|
|
||||||
│ └── images/
|
|
||||||
│ └── favicon.png
|
|
||||||
├── config/
|
|
||||||
│ └── config.php # Config + .env loading
|
|
||||||
├── controllers/ # MVC Controllers
|
|
||||||
│ ├── DashboardController.php # Dashboard with assigned_to column
|
|
||||||
│ └── TicketController.php # Ticket CRUD + timeline + templates
|
|
||||||
├── models/ # Data models
|
|
||||||
│ ├── AuditLogModel.php # Audit logging + timeline
|
|
||||||
│ ├── BulkOperationsModel.php # Bulk operations tracking (NEW)
|
|
||||||
│ ├── CommentModel.php # Comment data access
|
|
||||||
│ ├── TemplateModel.php # Ticket templates (NEW)
|
|
||||||
│ ├── TicketModel.php # Ticket CRUD + assignment
|
|
||||||
│ ├── UserModel.php # User management (NEW)
|
|
||||||
│ └── WorkflowModel.php # Status transition workflows (NEW)
|
|
||||||
├── views/ # PHP templates
|
|
||||||
│ ├── CreateTicketView.php # Ticket creation with templates
|
|
||||||
│ ├── DashboardView.php # Dashboard with bulk actions + assigned column
|
|
||||||
│ └── TicketView.php # Ticket view with timeline + assignment
|
|
||||||
├── migrations/ # Database migrations
|
|
||||||
│ ├── 001_initial_schema.sql
|
|
||||||
│ ├── 007_add_ticket_assignment.sql # Ticket assignment
|
|
||||||
│ ├── 008_add_status_workflows.sql # Workflow rules
|
|
||||||
│ ├── 009_add_ticket_templates.sql # Ticket templates
|
|
||||||
│ ├── 010_add_bulk_operations.sql # Bulk operations
|
|
||||||
│ └── 011_remove_view_tracking.sql # Remove view audit logs
|
|
||||||
├── .env # Environment variables (GITIGNORED)
|
|
||||||
├── Claude.md # This file
|
|
||||||
├── README.md # User documentation
|
|
||||||
├── index.php # Dashboard entry point
|
|
||||||
└── ticket.php # Ticket view/create router
|
|
||||||
```
|
|
||||||
|
|
||||||
## Database Schema (Updated)
|
|
||||||
|
|
||||||
**Database**: `ticketing_system` at 10.10.10.50
|
|
||||||
**User**: `tinkertickets`
|
|
||||||
**Connection**: All APIs create their own connections via config.php
|
|
||||||
|
|
||||||
### Core Tables
|
|
||||||
|
|
||||||
#### `tickets` Table (Updated)
|
|
||||||
```sql
|
|
||||||
CREATE TABLE tickets (
|
|
||||||
ticket_id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
title VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
status VARCHAR(50) DEFAULT 'Open',
|
|
||||||
priority INT DEFAULT 4,
|
|
||||||
category VARCHAR(50) DEFAULT 'General',
|
|
||||||
type VARCHAR(50) DEFAULT 'Issue',
|
|
||||||
created_by INT, -- User who created
|
|
||||||
updated_by INT, -- User who last updated
|
|
||||||
assigned_to INT, -- User assigned to (NEW)
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (created_by) REFERENCES users(user_id),
|
|
||||||
FOREIGN KEY (updated_by) REFERENCES users(user_id),
|
|
||||||
FOREIGN KEY (assigned_to) REFERENCES users(user_id) ON DELETE SET NULL,
|
|
||||||
INDEX idx_status (status),
|
|
||||||
INDEX idx_assigned_to (assigned_to)
|
|
||||||
) ENGINE=InnoDB;
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `users` Table (SSO Integration)
|
|
||||||
```sql
|
|
||||||
CREATE TABLE users (
|
|
||||||
user_id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
username VARCHAR(100) UNIQUE NOT NULL,
|
|
||||||
display_name VARCHAR(255),
|
|
||||||
email VARCHAR(255),
|
|
||||||
is_admin BOOLEAN DEFAULT FALSE,
|
|
||||||
last_login TIMESTAMP NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
) ENGINE=InnoDB;
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `comments` Table
|
|
||||||
```sql
|
|
||||||
CREATE TABLE comments (
|
|
||||||
comment_id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
ticket_id INT NOT NULL,
|
|
||||||
user_id INT,
|
|
||||||
comment_text TEXT NOT NULL,
|
|
||||||
markdown_enabled BOOLEAN DEFAULT FALSE,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (ticket_id) REFERENCES tickets(ticket_id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(user_id),
|
|
||||||
INDEX idx_ticket_id (ticket_id)
|
|
||||||
) ENGINE=InnoDB;
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `audit_log` Table (Activity Timeline)
|
|
||||||
```sql
|
|
||||||
CREATE TABLE audit_log (
|
|
||||||
log_id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
user_id INT,
|
|
||||||
action_type VARCHAR(50) NOT NULL, -- 'create', 'update', 'comment', 'assign', etc.
|
|
||||||
entity_type VARCHAR(50) NOT NULL, -- 'ticket', 'comment'
|
|
||||||
entity_id INT NOT NULL, -- ticket_id or comment_id
|
|
||||||
details JSON, -- JSON details of what changed
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(user_id),
|
|
||||||
INDEX idx_entity (entity_type, entity_id),
|
|
||||||
INDEX idx_user (user_id),
|
|
||||||
INDEX idx_action (action_type)
|
|
||||||
) ENGINE=InnoDB;
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `status_transitions` Table (Workflow Rules)
|
|
||||||
```sql
|
|
||||||
CREATE TABLE status_transitions (
|
|
||||||
transition_id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
from_status VARCHAR(50) NOT NULL,
|
|
||||||
to_status VARCHAR(50) NOT NULL,
|
|
||||||
requires_comment BOOLEAN DEFAULT FALSE, -- Transition requires comment
|
|
||||||
requires_admin BOOLEAN DEFAULT FALSE, -- Transition requires admin
|
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE KEY unique_transition (from_status, to_status),
|
|
||||||
INDEX idx_from_status (from_status)
|
|
||||||
) ENGINE=InnoDB;
|
|
||||||
```
|
|
||||||
|
|
||||||
Default transitions:
|
|
||||||
```sql
|
|
||||||
-- Open → In Progress, Closed, Resolved
|
|
||||||
-- In Progress → Open, Closed, Resolved
|
|
||||||
-- Resolved → Closed, In Progress
|
|
||||||
-- Closed → Open, In Progress (requires comment)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `ticket_templates` Table
|
|
||||||
```sql
|
|
||||||
CREATE TABLE ticket_templates (
|
|
||||||
template_id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
template_name VARCHAR(100) NOT NULL,
|
|
||||||
title_template VARCHAR(255) NOT NULL,
|
|
||||||
description_template TEXT NOT NULL,
|
|
||||||
category VARCHAR(50),
|
|
||||||
type VARCHAR(50),
|
|
||||||
default_priority INT DEFAULT 4,
|
|
||||||
created_by INT,
|
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (created_by) REFERENCES users(user_id),
|
|
||||||
INDEX idx_template_name (template_name)
|
|
||||||
) ENGINE=InnoDB;
|
|
||||||
```
|
|
||||||
|
|
||||||
Default templates: Hardware Failure, Software Installation, Network Issue, Maintenance Request
|
|
||||||
|
|
||||||
#### `bulk_operations` Table
|
|
||||||
```sql
|
|
||||||
CREATE TABLE bulk_operations (
|
|
||||||
operation_id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
operation_type VARCHAR(50) NOT NULL, -- 'bulk_close', 'bulk_assign', 'bulk_priority'
|
|
||||||
ticket_ids TEXT NOT NULL, -- Comma-separated ticket IDs
|
|
||||||
performed_by INT NOT NULL,
|
|
||||||
parameters JSON, -- Operation parameters
|
|
||||||
status VARCHAR(20) DEFAULT 'pending',
|
|
||||||
total_tickets INT,
|
|
||||||
processed_tickets INT DEFAULT 0,
|
|
||||||
failed_tickets INT DEFAULT 0,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
completed_at TIMESTAMP NULL,
|
|
||||||
FOREIGN KEY (performed_by) REFERENCES users(user_id),
|
|
||||||
INDEX idx_performed_by (performed_by),
|
|
||||||
INDEX idx_created_at (created_at)
|
|
||||||
) ENGINE=InnoDB;
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Endpoints (Updated)
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
All API endpoints check: `$_SESSION['user']['user_id']` for authentication.
|
|
||||||
Admin-only endpoints check: `$_SESSION['user']['is_admin']`.
|
|
||||||
|
|
||||||
### POST `/api/update_ticket.php`
|
|
||||||
Updates ticket with workflow validation.
|
|
||||||
|
|
||||||
**Request:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ticket_id": 123,
|
|
||||||
"status": "In Progress", // Validated against workflow rules
|
|
||||||
"priority": 2,
|
|
||||||
"title": "Updated title",
|
|
||||||
"description": "...",
|
|
||||||
"category": "Software",
|
|
||||||
"type": "Task"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"status": "In Progress",
|
|
||||||
"priority": 2,
|
|
||||||
"message": "Ticket updated successfully"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Workflow validation via WorkflowModel
|
|
||||||
- Partial updates (only send changed fields)
|
|
||||||
- User tracking (updated_by)
|
|
||||||
- Discord webhook notifications
|
|
||||||
- Audit logging
|
|
||||||
|
|
||||||
### POST `/api/assign_ticket.php` (NEW)
|
|
||||||
Assigns ticket to a user.
|
|
||||||
|
|
||||||
**Request:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ticket_id": 123,
|
|
||||||
"assigned_to": 5 // user_id, or null to unassign
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### GET `/api/get_users.php` (NEW)
|
|
||||||
Returns list of all users for assignment dropdowns.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"users": [
|
|
||||||
{
|
|
||||||
"user_id": 1,
|
|
||||||
"username": "jared",
|
|
||||||
"display_name": "Jared Vititoe",
|
|
||||||
"is_admin": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### GET `/api/get_template.php?template_id=1` (NEW)
|
|
||||||
Fetches a ticket template.
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"template": {
|
|
||||||
"template_id": 1,
|
|
||||||
"template_name": "Hardware Failure",
|
|
||||||
"title_template": "Hardware Failure: [Device Name]",
|
|
||||||
"description_template": "Device: \nIssue: \n...",
|
|
||||||
"category": "Hardware",
|
|
||||||
"type": "Problem",
|
|
||||||
"default_priority": 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### POST `/api/bulk_operation.php` (NEW - ADMIN ONLY)
|
|
||||||
Performs bulk operations on tickets.
|
|
||||||
|
|
||||||
**Request:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"operation_type": "bulk_close", // or 'bulk_assign', 'bulk_priority'
|
|
||||||
"ticket_ids": [123, 456, 789],
|
|
||||||
"parameters": { // For bulk_assign or bulk_priority
|
|
||||||
"assigned_to": 5, // For bulk_assign
|
|
||||||
"priority": 2 // For bulk_priority
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"operation_id": 42,
|
|
||||||
"processed": 3,
|
|
||||||
"failed": 0,
|
|
||||||
"message": "Bulk operation completed: 3 succeeded, 0 failed"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### POST `/api/add_comment.php`
|
|
||||||
Adds comment to ticket.
|
|
||||||
|
|
||||||
**Request:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ticket_id": 123,
|
|
||||||
"comment_text": "Comment content",
|
|
||||||
"markdown_enabled": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"user_name": "Jared Vititoe",
|
|
||||||
"created_at": "Jan 01, 2026 12:00"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Features Implementation
|
|
||||||
|
|
||||||
### Feature 1: Activity Timeline
|
|
||||||
**Location**: Ticket view → Activity tab
|
|
||||||
|
|
||||||
**Implementation**:
|
|
||||||
- `AuditLogModel->getTicketTimeline()` - Fetches all events for a ticket
|
|
||||||
- Shows: creates, updates, comments, assignments, status changes
|
|
||||||
- Displays: user, action, timestamp, details
|
|
||||||
- CSS: timeline-content boxes with icons
|
|
||||||
- Dark mode: Fully supported
|
|
||||||
|
|
||||||
**Code**: `views/TicketView.php:258-282`, `models/AuditLogModel.php:getTicketTimeline()`
|
|
||||||
|
|
||||||
### Feature 2: Ticket Assignment
|
|
||||||
**Location**: Ticket view → "Assigned to" dropdown, Dashboard → "Assigned To" column
|
|
||||||
|
|
||||||
**Implementation**:
|
|
||||||
- Database: `tickets.assigned_to` column
|
|
||||||
- Models: `TicketModel->assignTicket()`, `TicketModel->unassignTicket()`
|
|
||||||
- API: `api/assign_ticket.php`
|
|
||||||
- Dashboard: Shows assigned user in table
|
|
||||||
- Auto-saves on change
|
|
||||||
- Audit logged
|
|
||||||
|
|
||||||
**Code**: `views/TicketView.php:170-181`, `assets/js/ticket.js:handleAssignmentChange()`
|
|
||||||
|
|
||||||
### Feature 3: Status Transitions with Workflows
|
|
||||||
**Location**: Ticket view → Status dropdown (header)
|
|
||||||
|
|
||||||
**Implementation**:
|
|
||||||
- Database: `status_transitions` table defines allowed transitions
|
|
||||||
- Models: `WorkflowModel->isTransitionAllowed()`, `WorkflowModel->getAllowedTransitions()`
|
|
||||||
- Dropdown shows only valid transitions for current status
|
|
||||||
- Server-side validation prevents invalid changes
|
|
||||||
- Can require comments or admin privileges
|
|
||||||
- Removed from hamburger menu (was duplicate)
|
|
||||||
|
|
||||||
**Code**: `models/WorkflowModel.php`, `api/update_ticket.php:130-144`, `views/TicketView.php:185-198`
|
|
||||||
|
|
||||||
### Feature 4: Ticket Templates
|
|
||||||
**Location**: Create ticket page → Template selector
|
|
||||||
|
|
||||||
**Implementation**:
|
|
||||||
- Database: `ticket_templates` table
|
|
||||||
- Models: `TemplateModel->getAllTemplates()`, `TemplateModel->getTemplateById()`
|
|
||||||
- API: `api/get_template.php`
|
|
||||||
- JavaScript: `loadTemplate()` in dashboard.js
|
|
||||||
- Auto-fills: title, description, category, type, priority
|
|
||||||
- 4 default templates included
|
|
||||||
|
|
||||||
**Code**: `views/CreateTicketView.php:27-39`, `assets/js/dashboard.js:loadTemplate()`
|
|
||||||
|
|
||||||
### Feature 5: Bulk Actions (Admin Only)
|
|
||||||
**Location**: Dashboard → Checkboxes + Toolbar (admins only)
|
|
||||||
|
|
||||||
**Implementation**:
|
|
||||||
- Database: `bulk_operations` table tracks operations
|
|
||||||
- Models: `BulkOperationsModel->processBulkOperation()`
|
|
||||||
- API: `api/bulk_operation.php`
|
|
||||||
- UI: Toolbar appears when tickets selected
|
|
||||||
- Operations: Bulk close, bulk assign, bulk priority
|
|
||||||
- All operations audit logged
|
|
||||||
- Server-side admin validation
|
|
||||||
|
|
||||||
**Code**: `views/DashboardView.php:176-188`, `assets/js/dashboard.js:bulkClose()`, `models/BulkOperationsModel.php`
|
|
||||||
|
|
||||||
## Authentication & SSO Integration
|
|
||||||
|
|
||||||
### Authelia Integration
|
|
||||||
User information passed via HTTP headers:
|
|
||||||
- `Remote-User`: Username
|
|
||||||
- `Remote-Name`: Display name
|
|
||||||
- `Remote-Email`: Email
|
|
||||||
- `Remote-Groups`: Comma-separated groups
|
|
||||||
|
|
||||||
### Session Management
|
|
||||||
```php
|
|
||||||
$_SESSION['user'] = [
|
|
||||||
'user_id' => 123,
|
|
||||||
'username' => 'jared',
|
|
||||||
'display_name' => 'Jared Vititoe',
|
|
||||||
'email' => 'jared@lotusguild.org',
|
|
||||||
'is_admin' => true // true if 'admins' in Remote-Groups
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
### Admin Privileges
|
|
||||||
- Bulk operations (close, assign, priority)
|
|
||||||
- Future: Admin-only transitions
|
|
||||||
|
|
||||||
## Frontend Components (Updated)
|
|
||||||
|
|
||||||
### Dashboard (`DashboardView.php` + `dashboard.js`)
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
- Sortable columns including new "Assigned To" column
|
|
||||||
- Search (title, description, ticket_id, category, type)
|
|
||||||
- Status filtering (default: Open + In Progress)
|
|
||||||
- Pagination (configurable)
|
|
||||||
- Dark mode toggle
|
|
||||||
- **Bulk Actions Toolbar** (admin only):
|
|
||||||
- Checkboxes on each ticket
|
|
||||||
- "Select All" checkbox
|
|
||||||
- Bulk close, assign, priority buttons
|
|
||||||
- Shows count of selected tickets
|
|
||||||
|
|
||||||
**Hamburger Menu**:
|
|
||||||
- Category/Type filtering
|
|
||||||
- Apply/Clear filters
|
|
||||||
- No status field (removed)
|
|
||||||
|
|
||||||
### Ticket View (`TicketView.php` + `ticket.js`)
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
- **Tabbed Interface**: Description, Comments, Activity
|
|
||||||
- **Activity Timeline**: Complete audit trail with icons
|
|
||||||
- **Assignment Dropdown**: Assign to users
|
|
||||||
- **Status Dropdown**: Workflow-validated status changes (header)
|
|
||||||
- **Hamburger Menu**: Priority, Category, Type editing
|
|
||||||
- **Edit Button**: Title and description editing
|
|
||||||
- **Markdown Comments**: With live preview
|
|
||||||
- **Dark Mode**: Comprehensive support
|
|
||||||
|
|
||||||
**Visual Indicators**:
|
|
||||||
- Priority colors (P1=Red, P2=Orange, P3=Blue, P4=Green, P5=Gray)
|
|
||||||
- Status badges (Open=Green, In Progress=Yellow, Closed=Red, Resolved=Green)
|
|
||||||
- Priority border colors on ticket container
|
|
||||||
|
|
||||||
### Create Ticket (`CreateTicketView.php`)
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
- **Template Selector**: Quick-fill from templates
|
|
||||||
- Standard fields: title, description, status, priority, category, type
|
|
||||||
- Form validation
|
|
||||||
- Discord webhook on creation
|
|
||||||
|
|
||||||
## Dark Mode (Fixed)
|
|
||||||
|
|
||||||
### Comprehensive Dark Mode CSS
|
|
||||||
**Files**: `assets/css/ticket.css`, `assets/css/dashboard.css`
|
|
||||||
|
|
||||||
**Colors**:
|
|
||||||
```css
|
|
||||||
body.dark-mode {
|
|
||||||
--bg-primary: #1a202c; /* Main background */
|
|
||||||
--bg-secondary: #2d3748; /* Cards, inputs */
|
|
||||||
--bg-tertiary: #4a5568; /* Hover states */
|
|
||||||
--text-primary: #e2e8f0; /* Main text */
|
|
||||||
--text-secondary: #cbd5e0; /* Secondary text */
|
|
||||||
--text-muted: #a0aec0; /* Muted text */
|
|
||||||
--border-color: #4a5568; /* Borders */
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Fixed Elements**:
|
|
||||||
- Timeline boxes (background + text)
|
|
||||||
- Bulk actions toolbar
|
|
||||||
- Tables and table rows
|
|
||||||
- Input fields and textareas
|
|
||||||
- Dropdowns and selects
|
|
||||||
- Comment boxes
|
|
||||||
- Modal dialogs
|
|
||||||
- All text elements
|
|
||||||
|
|
||||||
**Important**: Used `!important` flags to override any conflicting styles.
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Environment Variables (`.env`)
|
|
||||||
```ini
|
|
||||||
DB_HOST=10.10.10.50
|
|
||||||
DB_USER=tinkertickets
|
|
||||||
DB_PASS=password
|
|
||||||
DB_NAME=ticketing_system
|
|
||||||
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
|
||||||
```
|
|
||||||
|
|
||||||
**CRITICAL**: `.env` is gitignored! Never commit this file.
|
|
||||||
|
|
||||||
### Apache Configuration
|
|
||||||
**Virtual Host**: Apache serving from `/root/code/tinker_tickets`
|
|
||||||
|
|
||||||
```apache
|
|
||||||
<VirtualHost *:80>
|
|
||||||
ServerName t.lotusguild.org
|
|
||||||
DocumentRoot /root/code/tinker_tickets
|
|
||||||
|
|
||||||
<Directory /root/code/tinker_tickets>
|
|
||||||
Options -Indexes +FollowSymLinks
|
|
||||||
AllowOverride All
|
|
||||||
Require all granted
|
|
||||||
|
|
||||||
RewriteEngine On
|
|
||||||
RewriteBase /
|
|
||||||
RewriteRule ^ticket/([0-9]+)$ ticket.php?id=$1 [L,QSA]
|
|
||||||
RewriteRule ^ticket/create$ ticket.php?action=create [L,QSA]
|
|
||||||
</Directory>
|
|
||||||
</VirtualHost>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
### Git Auto-Deploy
|
|
||||||
**Repository**: https://code.lotusguild.org/LotusGuild/tinker_tickets
|
|
||||||
|
|
||||||
**Flow**:
|
|
||||||
1. Push to `main` branch
|
|
||||||
2. Auto-deploys to `/root/code/tinker_tickets` on 10.10.10.45
|
|
||||||
3. `.env` is preserved
|
|
||||||
4. Migrations must be run manually
|
|
||||||
|
|
||||||
### Running Migrations
|
|
||||||
```bash
|
|
||||||
cd /root/code/tinker_tickets/migrations
|
|
||||||
mysql -h 10.10.10.50 -u tinkertickets -p'pass' ticketing_system < 007_add_ticket_assignment.sql
|
|
||||||
mysql -h 10.10.10.50 -u tinkertickets -p'pass' ticketing_system < 008_add_status_workflows.sql
|
|
||||||
mysql -h 10.10.10.50 -u tinkertickets -p'pass' ticketing_system < 009_add_ticket_templates.sql
|
|
||||||
mysql -h 10.10.10.50 -u tinkertickets -p'pass' ticketing_system < 010_add_bulk_operations.sql
|
|
||||||
mysql -h 10.10.10.50 -u tinkertickets -p'pass' ticketing_system < 011_remove_view_tracking.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development Guidelines
|
|
||||||
|
|
||||||
### Code Style
|
|
||||||
- **PHP**: Tabs for indentation, prepared statements, `htmlspecialchars()` for output
|
|
||||||
- **JavaScript**: Vanilla JS, `fetch()` for AJAX, clear function names
|
|
||||||
- **CSS**: CSS variables for theming, mobile-responsive
|
|
||||||
- **Security**: No SQL injection, XSS prevention, session validation
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
- APIs return JSON with `{success: bool, error: string}`
|
|
||||||
- Debug logging to `/tmp/api_debug.log` (update_ticket.php)
|
|
||||||
- User-friendly error messages
|
|
||||||
|
|
||||||
### Adding New Features
|
|
||||||
1. **Database**: Create migration in `migrations/`
|
|
||||||
2. **Model**: Add methods to relevant Model class
|
|
||||||
3. **API**: Create API endpoint in `api/` (with auth check)
|
|
||||||
4. **Controller**: Update controller to load data
|
|
||||||
5. **View**: Add UI elements
|
|
||||||
6. **JavaScript**: Add interactivity
|
|
||||||
7. **CSS**: Style for light + dark mode
|
|
||||||
8. **Test**: Test thoroughly before pushing
|
|
||||||
|
|
||||||
## ANSI Art Redesign (Next Priority)
|
|
||||||
|
|
||||||
### Goal
|
|
||||||
Transform Tinker Tickets into a retro terminal aesthetic using ANSI art and ASCII characters.
|
|
||||||
|
|
||||||
### Design Concept
|
|
||||||
- **Terminal-style borders**: Use box-drawing characters (┌─┐│└─┘)
|
|
||||||
- **Monospace fonts**: Courier New, Consolas, Monaco
|
|
||||||
- **ASCII art headers**: Stylized "TINKER TICKETS" banner
|
|
||||||
- **Retro color palette**: Green terminal, amber terminal, or custom
|
|
||||||
- **Template objects**: Reusable border/box components
|
|
||||||
|
|
||||||
### Implementation Approach
|
|
||||||
1. **CSS Variables**: Define ANSI color palette
|
|
||||||
2. **Border Components**: Create CSS classes for boxes with ASCII borders
|
|
||||||
3. **Typography**: Monospace fonts throughout
|
|
||||||
4. **Icons**: Replace emoji with ASCII art
|
|
||||||
5. **Dashboard**: Terminal-style table with borders
|
|
||||||
6. **Tickets**: Box-drawing characters for sections
|
|
||||||
7. **Forms**: Terminal-style input boxes
|
|
||||||
|
|
||||||
### Reference Colors (Classic Terminal)
|
|
||||||
```css
|
|
||||||
:root {
|
|
||||||
--ansi-black: #000000;
|
|
||||||
--ansi-green: #00ff00;
|
|
||||||
--ansi-amber: #ffb000;
|
|
||||||
--ansi-blue: #0000ff;
|
|
||||||
--ansi-cyan: #00ffff;
|
|
||||||
--ansi-white: #ffffff;
|
|
||||||
--ansi-bg: #000000;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example Box Template
|
|
||||||
```
|
|
||||||
┌─────────────────────────────┐
|
|
||||||
│ TICKET #123 │
|
|
||||||
├─────────────────────────────┤
|
|
||||||
│ Title: Hardware Failure │
|
|
||||||
│ Status: [OPEN] │
|
|
||||||
│ Priority: P1 (CRITICAL) │
|
|
||||||
└─────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Debugging
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
```bash
|
|
||||||
# API debug logs
|
|
||||||
tail -f /tmp/api_debug.log
|
|
||||||
|
|
||||||
# Database connection
|
|
||||||
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system
|
|
||||||
|
|
||||||
# JavaScript console
|
|
||||||
# Open browser DevTools (F12) → Console tab
|
|
||||||
|
|
||||||
# Check dark mode
|
|
||||||
# localStorage.getItem('theme')
|
|
||||||
```
|
|
||||||
|
|
||||||
### Known Behaviors
|
|
||||||
- Ticket viewing no longer tracked (011 migration removes view logs)
|
|
||||||
- Status can only be changed via header dropdown (removed from hamburger)
|
|
||||||
- Bulk actions only visible to admins
|
|
||||||
- Templates are optional when creating tickets
|
|
||||||
- Workflow validation prevents invalid status transitions
|
|
||||||
|
|
||||||
## Important Notes for AI Assistants
|
|
||||||
|
|
||||||
1. **All 5 features are complete and deployed**
|
|
||||||
2. **Dark mode is fixed** with comprehensive CSS
|
|
||||||
3. **Next priority is ANSI Art redesign** (major visual overhaul)
|
|
||||||
4. **Database at 10.10.10.50**, can't access directly from dev machine
|
|
||||||
5. **Auto-deploy is active**, test carefully before pushing
|
|
||||||
6. **Session format**: `$_SESSION['user']['user_id']` (not `$_SESSION['user_id']`)
|
|
||||||
7. **API auth**: Check `$_SESSION['user']['user_id']` exists
|
|
||||||
8. **Admin check**: `$_SESSION['user']['is_admin'] ?? false`
|
|
||||||
9. **Config path**: `config/config.php` (not `config/db.php`)
|
|
||||||
10. **Migrations**: Must be run manually on database server
|
|
||||||
|
|
||||||
## File Reference Quick Guide
|
|
||||||
|
|
||||||
| File | Purpose | Key Functions |
|
|
||||||
|------|---------|---------------|
|
|
||||||
| `index.php` | Dashboard router | Database connection, routing |
|
|
||||||
| `ticket.php` | Ticket router | View/create ticket routing |
|
|
||||||
| `api/update_ticket.php` | Update API | Workflow validation, partial updates |
|
|
||||||
| `api/assign_ticket.php` | Assignment API | Assign/unassign tickets |
|
|
||||||
| `api/bulk_operation.php` | Bulk ops API | Admin bulk operations |
|
|
||||||
| `api/get_template.php` | Template API | Fetch ticket templates |
|
|
||||||
| `api/get_users.php` | Users API | Get user list |
|
|
||||||
| `models/TicketModel.php` | Ticket data | CRUD, assignment, filtering |
|
|
||||||
| `models/WorkflowModel.php` | Workflow rules | Status transition validation |
|
|
||||||
| `models/AuditLogModel.php` | Audit logging | Timeline, activity tracking |
|
|
||||||
| `models/TemplateModel.php` | Templates | Template CRUD |
|
|
||||||
| `models/BulkOperationsModel.php` | Bulk ops | Process bulk operations |
|
|
||||||
| `controllers/DashboardController.php` | Dashboard logic | Pagination, filters, assigned column |
|
|
||||||
| `controllers/TicketController.php` | Ticket logic | CRUD, timeline, templates |
|
|
||||||
| `assets/js/dashboard.js` | Dashboard UI | Filters, bulk actions, templates |
|
|
||||||
| `assets/js/ticket.js` | Ticket UI | Status updates, assignment, comments |
|
|
||||||
| `assets/css/dashboard.css` | Dashboard styles | Layout, table, bulk toolbar, dark mode |
|
|
||||||
| `assets/css/ticket.css` | Ticket styles | Timeline, ticket view, dark mode |
|
|
||||||
|
|
||||||
## Repository & Contact
|
|
||||||
|
|
||||||
- **Gitea**: https://code.lotusguild.org/LotusGuild/tinker_tickets
|
|
||||||
- **Production**: http://t.lotusguild.org
|
|
||||||
- **Infrastructure**: LotusGuild data center management
|
|
||||||
@@ -1,142 +1,475 @@
|
|||||||
# Tinker Tickets
|
# Tinker Tickets
|
||||||
|
|
||||||
A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management.
|
[](https://code.lotusguild.org/LotusGuild/tinker_tickets/actions?workflow=lint.yml)
|
||||||
|
|
||||||
## ✨ Core Features
|
A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management and a retro terminal aesthetic.
|
||||||
|
|
||||||
### 📊 Dashboard & Ticket Management
|
**Documentation**: [Wiki](https://wiki.lotusguild.org/en/Services/service-tinker-tickets)
|
||||||
- **Smart Dashboard**: Sortable columns, advanced filtering by status/priority/category/type
|
**Design System**: [web_template](https://code.lotusguild.org/LotusGuild/web_template) — shared CSS, JS, and layout patterns for all LotusGuild apps
|
||||||
|
|
||||||
|
## Styling & Layout
|
||||||
|
|
||||||
|
Tinker Tickets uses the **LotusGuild Terminal Design System**. For all styling, component, and layout documentation see:
|
||||||
|
|
||||||
|
- [`web_template/README.md`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/README.md) — full component reference, CSS variables, JS API
|
||||||
|
- [`web_template/base.css`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/base.css) — unified CSS (`.lt-*` classes)
|
||||||
|
- [`web_template/base.js`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/base.js) — `window.lt` utilities (toast, modal, CSRF, fetch helpers)
|
||||||
|
- [`web_template/php/layout.php`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/php/layout.php) — PHP base layout template
|
||||||
|
|
||||||
|
**Key conventions:**
|
||||||
|
- All `.lt-*` CSS classes come from `base.css` — do not duplicate them in `assets/css/`
|
||||||
|
- All `lt.*` JS utilities come from `base.js` — use `lt.toast`, `lt.modal`, `lt.api`, etc.
|
||||||
|
- CSP nonces: every `<script>` tag needs `nonce="<?php echo $nonce; ?>"`
|
||||||
|
- CSRF: inject `window.CSRF_TOKEN` via the nonce-protected inline script block; `lt.api.*` adds the header automatically
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
The following features are intentionally **not planned** for this system:
|
||||||
|
- **Email Integration**: Matrix (hookshot webhook) is the chosen external notification method
|
||||||
|
- **Time Tracking**: Out of scope for current requirements
|
||||||
|
- **OAuth2/External Identity Providers**: Authelia is the only approved SSO method
|
||||||
|
|
||||||
|
## Core Features
|
||||||
|
|
||||||
|
### Dashboard & Ticket Management
|
||||||
|
- **View Modes**: Toggle between Table view and Kanban card view (drag-and-drop status changes)
|
||||||
|
- **Right Drawer Preview**: Click any ticket title to open a quick-preview panel without navigating away
|
||||||
|
- **Stats Widgets**: Clickable cards for quick filtering (Open, Critical, Unassigned, Today's tickets) with live trend indicators
|
||||||
|
- **Charts**: Priority distribution donut, status breakdown donut, and category bar chart (Chart.js, CDN)
|
||||||
|
- **Team Workload**: Collapsible panel showing open ticket count per assignee with progress bars
|
||||||
- **Full-Text Search**: Search across tickets, descriptions, and metadata
|
- **Full-Text Search**: Search across tickets, descriptions, and metadata
|
||||||
- **Ticket Assignment**: Assign tickets to specific users with "Assigned To" column
|
- **Advanced Search**: Date ranges (Flatpickr), priority ranges, user filters
|
||||||
- **Priority Tracking**: P1 (Critical) to P5 (Minimal Impact) with color-coded indicators
|
- **Saved Filters**: Save and recall filter presets; quick-switch pills above the table
|
||||||
|
- **Column Visibility**: Toggle which dashboard table columns are shown; persisted in localStorage
|
||||||
|
- **Ticket Assignment**: Assign tickets to specific users with typeahead search
|
||||||
|
- **Priority Tracking**: P1 (Critical) to P5 (Minimal Impact) with color-coded indicators and status dots
|
||||||
- **Custom Categories**: Hardware, Software, Network, Security, General
|
- **Custom Categories**: Hardware, Software, Network, Security, General
|
||||||
- **Ticket Types**: Maintenance, Install, Task, Upgrade, Issue, Problem
|
- **Ticket Types**: Maintenance, Install, Task, Upgrade, Issue, Problem
|
||||||
|
- **Skeleton Loaders**: Loading placeholders during filter changes and data refresh
|
||||||
|
- **Export**: Export filtered tickets to CSV or JSON format
|
||||||
|
- **Ticket Linking**: Reference other tickets in comments using `#123456789` format
|
||||||
|
|
||||||
### 🔄 Workflow Management
|
### Ticket Visibility Levels
|
||||||
- **Status Transitions**: Enforced workflow rules (Open → In Progress → Resolved → Closed)
|
- **Public**: All authenticated users can view the ticket
|
||||||
|
- **Internal**: Only users in specified groups can view the ticket (at least one group required)
|
||||||
|
- **Confidential**: Only the creator, assignee, and admins can view the ticket
|
||||||
|
|
||||||
|
### Workflow Management
|
||||||
|
- **Status Transitions**: Enforced workflow rules (Open → Pending → In Progress → Closed)
|
||||||
|
- **Comment Requirements**: Transitions that require a comment open an inline modal before committing the change
|
||||||
|
- **Workflow Designer**: Visual admin UI at `/admin/workflow` to configure transitions
|
||||||
- **Workflow Validation**: Server-side validation prevents invalid status changes
|
- **Workflow Validation**: Server-side validation prevents invalid status changes
|
||||||
- **Admin Controls**: Certain transitions can require admin privileges
|
- **Admin Controls**: Certain transitions can require admin privileges
|
||||||
- **Comment Requirements**: Optional comment requirements for specific transitions
|
|
||||||
- **Activity Timeline**: Complete audit trail of all ticket changes
|
|
||||||
|
|
||||||
### 💬 Collaboration Features
|
### Collaboration Features
|
||||||
- **Markdown Comments**: Full Markdown support with live preview
|
- **Markdown Comments**: Full Markdown support with live preview, toolbar, and table rendering
|
||||||
- **User Tracking**: Tracks who created, updated, and assigned tickets
|
- **@Mentions**: Tag users in comments with `@` autocomplete (typeahead); triggers Matrix notification to mentioned user
|
||||||
- **Activity Timeline**: Shows all ticket events (creates, updates, assignments, comments)
|
- **Comment Edit/Delete**: Comment owners and admins can edit or delete comments
|
||||||
- **Real-time Updates**: AJAX-powered updates without page refreshes
|
- **Auto-linking**: URLs in comments are automatically converted to clickable links
|
||||||
|
- **File Attachments**: Upload files to tickets with drag-and-drop; image attachments display as thumbnails with lightbox zoom
|
||||||
|
- **Ticket Cloning**: Duplicate any ticket with a single click; auto-links as `relates_to`
|
||||||
|
- **Ticket Dependencies**: Link tickets as blocks / blocked-by / relates-to / duplicates
|
||||||
|
- **Duplicate Detection**: Similarity check on ticket title surfaces potential duplicates with one-click linking
|
||||||
|
- **Activity Timeline**: Full `lt-timeline` audit trail — color-coded by event type (status, comment, assign, attach)
|
||||||
|
- **Watcher Avatars**: Avatar group shows who is watching a ticket; tooltip lists all names
|
||||||
|
- **SLA Timer**: P1/P2 tickets display a live elapsed-time banner with progress bar (P1 = 8 h, P2 = 24 h, P3 = 72 h)
|
||||||
|
- **Priority Alert Banner**: P1 shows a sticky error banner; P2 shows a warning banner — dismissible per session
|
||||||
|
|
||||||
### 🎫 Ticket Templates
|
### Ticket Templates
|
||||||
|
- **Template Management**: Admin UI at `/admin/templates` to create/edit templates
|
||||||
- **Quick Creation**: Pre-configured templates for common issues
|
- **Quick Creation**: Pre-configured templates for common issues
|
||||||
- **Default Templates**: Hardware Failure, Software Installation, Network Issue, Maintenance
|
|
||||||
- **Auto-fill**: Templates populate title, description, category, type, and priority
|
- **Auto-fill**: Templates populate title, description, category, type, and priority
|
||||||
|
|
||||||
### 👥 User Management & Authentication
|
### Recurring Tickets
|
||||||
|
- **Scheduled Tickets**: Automatically create tickets on a schedule
|
||||||
|
- **Admin UI**: Manage at `/admin/recurring-tickets`
|
||||||
|
- **Flexible Scheduling**: Daily, weekly, or monthly recurrence
|
||||||
|
- **Cron Integration**: Run `cron/create_recurring_tickets.php` to process
|
||||||
|
|
||||||
|
### Custom Fields
|
||||||
|
- **Per-Category Fields**: Define custom fields for specific ticket categories
|
||||||
|
- **Admin UI**: Manage at `/admin/custom-fields`
|
||||||
|
- **Field Types**: Text, textarea, select, checkbox, date, number
|
||||||
|
- **Required Fields**: Mark fields as required for validation
|
||||||
|
|
||||||
|
### API Key Management
|
||||||
|
- **Admin UI**: Generate and manage API keys at `/admin/api-keys`
|
||||||
|
- **Bearer Token Auth**: Use API keys with `Authorization: Bearer YOUR_KEY` header
|
||||||
|
- **Expiration**: Optional expiration dates for keys
|
||||||
|
- **Revocation**: Revoke compromised keys instantly
|
||||||
|
|
||||||
|
### User Management & Authentication
|
||||||
- **SSO Integration**: Authelia authentication with LLDAP backend
|
- **SSO Integration**: Authelia authentication with LLDAP backend
|
||||||
- **Role-Based Access**: Admin and standard user roles
|
- **Role-Based Access**: Admin and standard user roles
|
||||||
- **User Display Names**: Support for display names and usernames
|
- **User Groups**: Groups displayed in settings modal, used for visibility
|
||||||
- **Session Management**: Secure PHP session handling
|
- **User Avatars**: JPEG avatars fetched from lldap via LDAP; cached locally (`/api/user_avatar.php`)
|
||||||
|
- **User Activity**: View per-user stats at `/admin/user-activity`
|
||||||
|
- **Session Management**: Secure PHP session handling with timeout
|
||||||
|
|
||||||
### ⚡ Bulk Actions (Admin Only)
|
### Bulk Actions (Admin Only)
|
||||||
- **Bulk Close**: Close multiple tickets at once
|
- **Bulk Close**: Close multiple tickets at once
|
||||||
- **Bulk Assign**: Assign multiple tickets to a user
|
- **Bulk Assign**: Assign multiple tickets to a user (typeahead search)
|
||||||
- **Bulk Priority**: Change priority for multiple tickets
|
- **Bulk Priority**: Change priority for multiple tickets
|
||||||
- **Operation Tracking**: All bulk operations logged in audit trail
|
- **Bulk Status**: Change status for multiple tickets
|
||||||
|
- **Checkbox Click Area**: Click anywhere in the checkbox cell to toggle
|
||||||
|
|
||||||
### 🔔 Notifications
|
### In-App Notifications
|
||||||
- **Discord Integration**: Webhook notifications for ticket creation and updates
|
- **Notification Bell**: Header bell icon with unread count badge; polls every 60 s
|
||||||
- **Rich Embeds**: Color-coded priority indicators and ticket links
|
- **Notification Sources**: Ticket assigned to you, comment on your ticket, status change on watched ticket, @mention
|
||||||
- **Change Tracking**: Detailed notification of what changed
|
- **Mark All Read**: Click the bell or "Mark all read" to clear the badge
|
||||||
|
- **Powered by audit_log**: No extra table — notifications are derived from existing audit trail
|
||||||
|
|
||||||
### 🎨 User Interface
|
### Matrix Notifications (hookshot)
|
||||||
- **Dark Mode**: Full dark mode support with proper contrast
|
- **Ticket Created**: Fires when any ticket is created (manual or via API)
|
||||||
- **Responsive Design**: Works on desktop and mobile devices
|
- **Status Changed**: Fires on every status transition
|
||||||
- **Clean Layout**: Modern, intuitive interface
|
- **@Mentions**: Mentioned users receive a direct Matrix notification
|
||||||
- **Hamburger Menu**: Quick access to ticket actions (priority, category, type)
|
- **Assignment**: Optional — set `MATRIX_NOTIFY_ASSIGNMENTS=1` to enable
|
||||||
|
- **Comments**: Optional — set `MATRIX_NOTIFY_COMMENTS=1` to enable
|
||||||
|
- **Watcher Alerts**: Watchers receive Matrix notifications on status changes (resolved via Synapse Admin API)
|
||||||
|
- **Rich Payloads**: JSON payloads sent to hookshot generic webhook; format ticket links using `APP_DOMAIN`
|
||||||
|
|
||||||
## 🏗️ Technical Architecture
|
### Command Palette (Ctrl+K)
|
||||||
|
- **Global Access**: Available on every page via `Ctrl+K` or `⌘K` button in header
|
||||||
|
- **Quick Navigation**: Dashboard, New Ticket, My Tickets, admin pages
|
||||||
|
- **Recent Tickets**: Last 5 viewed tickets (stored in localStorage)
|
||||||
|
- **Filter Shortcuts**: Apply common filters directly from palette
|
||||||
|
|
||||||
|
### Keyboard Shortcuts
|
||||||
|
| Shortcut | Action |
|
||||||
|
|----------|--------|
|
||||||
|
| `Ctrl/Cmd + K` | Open command palette (global) |
|
||||||
|
| `Ctrl/Cmd + E` | Toggle edit mode (ticket page) |
|
||||||
|
| `Ctrl/Cmd + S` | Save changes (ticket page) |
|
||||||
|
| `N` | New ticket (dashboard) |
|
||||||
|
| `J` / `K` | Next / previous row (dashboard table) |
|
||||||
|
| `Enter` | Open selected ticket (dashboard) |
|
||||||
|
| `G` then `D` | Go to dashboard |
|
||||||
|
| `1`–`4` | Quick status change (ticket page) |
|
||||||
|
| `ESC` | Cancel edit / close modal |
|
||||||
|
| `?` | Show keyboard shortcuts help |
|
||||||
|
|
||||||
|
### Security Features
|
||||||
|
- **CSRF Protection**: Token-based protection with constant-time comparison; token rotated after each write
|
||||||
|
- **Rate Limiting**: Session-based AND IP-based rate limiting to prevent abuse
|
||||||
|
- **Security Headers**: CSP with nonces (no unsafe-inline), X-Frame-Options, X-Content-Type-Options
|
||||||
|
- **SQL Injection Prevention**: All queries use prepared statements with parameter binding
|
||||||
|
- **XSS Protection**: HTML escaped in markdown parser, CSP headers block inline scripts
|
||||||
|
- **Audit Logging**: Complete audit trail of all actions
|
||||||
|
- **Visibility Enforcement**: Access checks on ticket views, downloads, and bulk operations
|
||||||
|
- **Collision-Safe IDs**: Ticket IDs verified unique before creation
|
||||||
|
|
||||||
|
## Automated Ticket Creation (hwmonDaemon)
|
||||||
|
|
||||||
|
[hwmonDaemon](https://code.lotusguild.org/LotusGuild/hwmonDaemon) runs on all servers and creates tickets automatically for hardware/health issues. It calls the **standalone API endpoint** at the document root:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /create_ticket_api.php
|
||||||
|
Authorization: Bearer <api_key>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"title": "[hostname][auto][production][hardware][single-node] SMART issues on /dev/sda",
|
||||||
|
"description": "...",
|
||||||
|
"priority": "2",
|
||||||
|
"category": "Hardware",
|
||||||
|
"type": "Issue"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key behaviours:**
|
||||||
|
- Authenticated via `Authorization: Bearer` header — API key stored in `/etc/hwmonDaemon/.env`
|
||||||
|
- **Deduplication**: Generates a SHA-256 hash from the issue category, hostname, and device; rejects duplicate tickets within 24 hours
|
||||||
|
- Cluster-wide issues (Ceph health, etc.) deduplicate across all nodes (hostname excluded from hash)
|
||||||
|
- Matrix notification sent automatically after ticket creation
|
||||||
|
- API key must be generated at `/admin/api-keys`; the key goes in hwmonDaemon's `/etc/hwmonDaemon/.env` as `TICKET_API_KEY`
|
||||||
|
|
||||||
|
## Technical Architecture
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
- **Language**: PHP 7.4+
|
- **Language**: PHP 7.4+
|
||||||
- **Database**: MariaDB/MySQL
|
- **Database**: MariaDB/MySQL
|
||||||
- **Architecture**: MVC pattern with models, views, controllers
|
- **Architecture**: MVC pattern with models, views, controllers
|
||||||
- **ORM**: Custom database abstraction layer
|
- **Authentication**: Authelia SSO with LLDAP backend
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
- **HTML5/CSS3**: Semantic markup with modern CSS
|
- **HTML5/CSS3**: Semantic markup with retro terminal styling
|
||||||
- **JavaScript**: Vanilla JS with Fetch API for AJAX
|
- **JavaScript**: Vanilla JS with Fetch API for AJAX
|
||||||
- **Markdown**: marked.js for Markdown rendering
|
- **Markdown**: Custom markdown parser with toolbar
|
||||||
- **Icons**: Unicode emoji icons
|
- **Terminal UI**: Box-drawing characters, monospace fonts, CRT effects
|
||||||
|
- **Mobile Responsive**: Touch-friendly controls, responsive layouts
|
||||||
|
- **Chart.js**: CDN-loaded on dashboard only — priority/status/category charts
|
||||||
|
- **Flatpickr**: CDN-loaded on dashboard only — date range filter pickers
|
||||||
|
|
||||||
### Database Schema
|
### Database Tables
|
||||||
- **tickets**: Core ticket data with user tracking
|
|
||||||
- **comments**: Markdown-supported comments
|
| Table | Purpose |
|
||||||
- **users**: User accounts synced from LLDAP
|
|-------|---------|
|
||||||
- **audit_log**: Complete audit trail with JSON details
|
| `tickets` | Core ticket data with visibility |
|
||||||
- **status_transitions**: Workflow configuration
|
| `ticket_comments` | Markdown-supported comments |
|
||||||
- **ticket_templates**: Reusable ticket templates
|
| `ticket_attachments` | File attachment metadata |
|
||||||
- **bulk_operations**: Tracking for bulk admin operations
|
| `ticket_dependencies` | Ticket relationships |
|
||||||
|
| `ticket_watchers` | Per-user ticket subscriptions |
|
||||||
|
| `users` | User accounts with groups |
|
||||||
|
| `user_preferences` | User settings (rows per page, notification opts, notif_last_seen) |
|
||||||
|
| `audit_log` | Complete audit trail (also powers in-app notifications) |
|
||||||
|
| `status_transitions` | Workflow configuration |
|
||||||
|
| `ticket_templates` | Reusable templates |
|
||||||
|
| `recurring_tickets` | Scheduled tickets |
|
||||||
|
| `custom_field_definitions` | Custom field schemas |
|
||||||
|
| `custom_field_values` | Custom field data |
|
||||||
|
| `saved_filters` | Saved filter combinations |
|
||||||
|
| `bulk_operations` | Bulk operation tracking |
|
||||||
|
| `api_keys` | API key storage with hashed keys |
|
||||||
|
|
||||||
|
#### `tickets` Table Key Columns
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `ticket_id` | varchar(9) | Unique 9-digit identifier |
|
||||||
|
| `visibility` | enum | `public`, `internal`, `confidential` |
|
||||||
|
| `visibility_groups` | varchar(500) | Comma-separated group names (for internal) |
|
||||||
|
| `created_by` | int | Foreign key to users |
|
||||||
|
| `assigned_to` | int | Foreign key to users (nullable) |
|
||||||
|
| `updated_by` | int | Foreign key to users |
|
||||||
|
| `priority` | int | 1–5 (1=Critical, 5=Minimal) |
|
||||||
|
| `status` | varchar(20) | Open, Pending, In Progress, Closed |
|
||||||
|
|
||||||
|
#### Indexed Columns (performance)
|
||||||
|
|
||||||
|
- `tickets`: `ticket_id` (unique), `status`, `priority`, `created_at`, `created_by`, `assigned_to`, `visibility`
|
||||||
|
- `audit_log`: `user_id`, `action_type`, `entity_type`, `created_at`
|
||||||
|
|
||||||
### API Endpoints
|
### API Endpoints
|
||||||
- `/api/update_ticket.php` - Update ticket with workflow validation
|
|
||||||
- `/api/assign_ticket.php` - Assign ticket to user
|
|
||||||
- `/api/add_comment.php` - Add comment to ticket
|
|
||||||
- `/api/get_template.php` - Fetch ticket template
|
|
||||||
- `/api/get_users.php` - Get user list for assignments
|
|
||||||
- `/api/bulk_operation.php` - Perform bulk operations (admin only)
|
|
||||||
|
|
||||||
## 🚀 Setup & Configuration
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/create_ticket_api.php` | POST | Create ticket via API key (hwmonDaemon, external tools) |
|
||||||
|
| `/api/update_ticket.php` | POST | Update ticket with workflow validation |
|
||||||
|
| `/api/assign_ticket.php` | POST | Assign ticket to user |
|
||||||
|
| `/api/add_comment.php` | POST | Add comment to ticket |
|
||||||
|
| `/api/clone_ticket.php` | POST | Clone an existing ticket |
|
||||||
|
| `/api/get_template.php` | GET | Fetch ticket template |
|
||||||
|
| `/api/get_users.php` | GET | Get user list for assignments |
|
||||||
|
| `/api/bulk_operation.php` | POST | Perform bulk operations |
|
||||||
|
| `/api/ticket_dependencies.php` | GET/POST/DELETE | Manage dependencies |
|
||||||
|
| `/api/upload_attachment.php` | GET/POST | List or upload attachments |
|
||||||
|
| `/api/export_tickets.php` | GET | Export tickets to CSV/JSON |
|
||||||
|
| `/api/generate_api_key.php` | POST | Generate API key (admin) |
|
||||||
|
| `/api/revoke_api_key.php` | POST | Revoke API key (admin) |
|
||||||
|
| `/api/delete_comment.php` | POST | Delete comment (owner/admin) |
|
||||||
|
| `/api/update_comment.php` | POST | Update comment (owner/admin) |
|
||||||
|
| `/api/delete_attachment.php` | POST/DELETE | Delete attachment |
|
||||||
|
| `/api/download_attachment.php` | GET | Download attachment (visibility checked) |
|
||||||
|
| `/api/check_duplicates.php` | GET | Check for duplicate tickets |
|
||||||
|
| `/api/manage_recurring.php` | CRUD | Recurring tickets (admin) |
|
||||||
|
| `/api/manage_templates.php` | CRUD | Templates (admin) |
|
||||||
|
| `/api/manage_workflows.php` | CRUD | Workflow rules (admin) |
|
||||||
|
| `/api/custom_fields.php` | CRUD | Custom field definitions/values (admin) |
|
||||||
|
| `/api/saved_filters.php` | CRUD | Saved filter combinations |
|
||||||
|
| `/api/user_preferences.php` | GET/POST | User preferences |
|
||||||
|
| `/api/notifications.php` | GET/POST | In-app notifications (bell) |
|
||||||
|
| `/api/user_avatar.php` | GET | User avatar from lldap (cached JPEG) |
|
||||||
|
| `/api/audit_log.php` | GET | Audit log entries (admin) |
|
||||||
|
| `/api/watch_ticket.php` | POST | Watch/unwatch a ticket |
|
||||||
|
| `/api/health.php` | GET | Health check |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tinker_tickets/
|
||||||
|
├── api/
|
||||||
|
│ ├── add_comment.php # POST: Add comment
|
||||||
|
│ ├── assign_ticket.php # POST: Assign ticket to user
|
||||||
|
│ ├── audit_log.php # GET: Audit log entries (admin)
|
||||||
|
│ ├── bootstrap.php # Shared auth/setup include (not a public endpoint)
|
||||||
|
│ ├── bulk_operation.php # POST: Bulk operations (admin only)
|
||||||
|
│ ├── check_duplicates.php # GET: Check for duplicate tickets
|
||||||
|
│ ├── clone_ticket.php # POST: Clone an existing ticket
|
||||||
|
│ ├── custom_fields.php # CRUD: Custom field definitions/values (admin)
|
||||||
|
│ ├── delete_attachment.php # POST/DELETE: Delete attachment
|
||||||
|
│ ├── delete_comment.php # POST: Delete comment (owner/admin)
|
||||||
|
│ ├── download_attachment.php # GET: Download with visibility check
|
||||||
|
│ ├── export_tickets.php # GET: Export tickets to CSV/JSON
|
||||||
|
│ ├── generate_api_key.php # POST: Generate API key (admin)
|
||||||
|
│ ├── get_template.php # GET: Fetch ticket template
|
||||||
|
│ ├── get_users.php # GET: Get user list
|
||||||
|
│ ├── health.php # GET: Health check endpoint
|
||||||
|
│ ├── manage_recurring.php # CRUD: Recurring tickets (admin)
|
||||||
|
│ ├── manage_templates.php # CRUD: Templates (admin)
|
||||||
|
│ ├── manage_workflows.php # CRUD: Workflow rules (admin)
|
||||||
|
│ ├── notifications.php # GET/POST: In-app notification bell
|
||||||
|
│ ├── revoke_api_key.php # POST: Revoke API key (admin)
|
||||||
|
│ ├── saved_filters.php # CRUD: Saved filter combinations
|
||||||
|
│ ├── ticket_dependencies.php # GET/POST/DELETE: Ticket dependencies
|
||||||
|
│ ├── update_comment.php # POST: Update comment (owner/admin)
|
||||||
|
│ ├── update_ticket.php # POST: Update ticket (workflow validation)
|
||||||
|
│ ├── upload_attachment.php # GET/POST: List or upload attachments
|
||||||
|
│ ├── user_avatar.php # GET: LDAP avatar proxy with disk cache
|
||||||
|
│ ├── user_preferences.php # GET/POST: User preferences
|
||||||
|
│ └── watch_ticket.php # POST: Watch/unwatch a ticket
|
||||||
|
├── assets/
|
||||||
|
│ ├── css/
|
||||||
|
│ │ ├── base.css # LotusGuild Terminal Design System (copied from web_template)
|
||||||
|
│ │ ├── dashboard.css # Dashboard + terminal styling
|
||||||
|
│ │ └── ticket.css # Ticket view styling
|
||||||
|
│ ├── js/
|
||||||
|
│ │ ├── advanced-search.js # Advanced search modal
|
||||||
|
│ │ ├── ascii-banner.js # ASCII art banner (rendered in boot overlay on first visit)
|
||||||
|
│ │ ├── base.js # LotusGuild JS utilities — window.lt (copied from web_template)
|
||||||
|
│ │ ├── dashboard.js # Dashboard + bulk actions + kanban + sidebar + charts
|
||||||
|
│ │ ├── keyboard-shortcuts.js # Keyboard shortcuts (uses lt.keys)
|
||||||
|
│ │ ├── markdown.js # Markdown rendering + ticket linking (XSS-safe)
|
||||||
|
│ │ ├── settings.js # User preferences
|
||||||
|
│ │ ├── ticket.js # Ticket + comments + visibility + @mention
|
||||||
|
│ │ ├── toast.js # Deprecated shim (no longer loaded — all callers use lt.toast directly)
|
||||||
|
│ │ └── utils.js # escapeHtml (→ lt.escHtml), getTicketIdFromUrl, showConfirmModal
|
||||||
|
│ └── images/
|
||||||
|
│ └── favicon.png
|
||||||
|
├── config/
|
||||||
|
│ └── config.php # Config + .env loading
|
||||||
|
├── controllers/
|
||||||
|
│ ├── DashboardController.php # Dashboard with stats + filters
|
||||||
|
│ └── TicketController.php # Ticket CRUD + timeline + visibility
|
||||||
|
├── cron/
|
||||||
|
│ └── create_recurring_tickets.php # Process recurring ticket schedules
|
||||||
|
├── helpers/
|
||||||
|
│ ├── CacheHelper.php # File-based cache (stats, avatars)
|
||||||
|
│ ├── Database.php # Centralized mysqli connection
|
||||||
|
│ ├── NotificationHelper.php # Matrix hookshot webhook events
|
||||||
|
│ ├── SynapseHelper.php # Resolves usernames → Matrix IDs via Synapse admin API
|
||||||
|
│ └── UrlHelper.php # Canonical ticket URLs using APP_DOMAIN
|
||||||
|
├── middleware/
|
||||||
|
│ ├── ApiKeyAuth.php # Bearer token auth for external API (hwmonDaemon)
|
||||||
|
│ ├── AuthMiddleware.php # Authelia SSO integration
|
||||||
|
│ ├── CsrfMiddleware.php # CSRF protection
|
||||||
|
│ ├── RateLimitMiddleware.php # Session + IP-based rate limiting
|
||||||
|
│ └── SecurityHeadersMiddleware.php # CSP headers with per-request nonce generation
|
||||||
|
├── models/
|
||||||
|
│ ├── ApiKeyModel.php # API key generation/validation
|
||||||
|
│ ├── AuditLogModel.php # Audit logging + timeline
|
||||||
|
│ ├── BulkOperationsModel.php # Bulk operations tracking
|
||||||
|
│ ├── CommentModel.php # Comment data access
|
||||||
|
│ ├── CustomFieldModel.php # Custom field definitions/values
|
||||||
|
│ ├── DependencyModel.php # Ticket dependencies
|
||||||
|
│ ├── RecurringTicketModel.php # Recurring ticket schedules
|
||||||
|
│ ├── SavedFiltersModel.php # Saved filter combinations
|
||||||
|
│ ├── StatsModel.php # Dashboard statistics (cached)
|
||||||
|
│ ├── TemplateModel.php # Ticket templates
|
||||||
|
│ ├── TicketModel.php # Ticket CRUD + visibility + collision-safe IDs
|
||||||
|
│ ├── UserModel.php # User management + groups
|
||||||
|
│ ├── UserPreferencesModel.php # User preferences
|
||||||
|
│ └── WorkflowModel.php # Status transition workflows
|
||||||
|
├── scripts/
|
||||||
|
│ ├── add_closed_at_column.php # Migration: add closed_at column to tickets
|
||||||
|
│ ├── add_comment_updated_at.php # Migration: add updated_at column to ticket_comments
|
||||||
|
│ ├── cleanup_orphan_uploads.php # Clean orphaned uploads (run manually or via cron)
|
||||||
|
│ └── create_dependencies_table.php # Create ticket_dependencies table
|
||||||
|
├── uploads/ # File attachment storage
|
||||||
|
│ └── avatars/ # lldap avatar disk cache
|
||||||
|
├── views/
|
||||||
|
│ ├── admin/
|
||||||
|
│ │ ├── ApiKeysView.php # API key management
|
||||||
|
│ │ ├── AuditLogView.php # Audit log browser
|
||||||
|
│ │ ├── CustomFieldsView.php # Custom field management
|
||||||
|
│ │ ├── RecurringTicketsView.php # Recurring ticket management
|
||||||
|
│ │ ├── TemplatesView.php # Template management
|
||||||
|
│ │ ├── UserActivityView.php # User activity report
|
||||||
|
│ │ └── WorkflowDesignerView.php # Workflow transition designer
|
||||||
|
│ ├── CreateTicketView.php # Ticket creation with visibility
|
||||||
|
│ ├── DashboardView.php # Dashboard with kanban + sidebar + charts
|
||||||
|
│ ├── layout_footer.php # Shared footer (notification polling, boot sequence)
|
||||||
|
│ ├── layout_header.php # Shared header (nav, command palette, theme toggle)
|
||||||
|
│ └── TicketView.php # Ticket view with timeline, SLA, watcher avatars
|
||||||
|
├── .env # Environment variables (GITIGNORED)
|
||||||
|
├── create_ticket_api.php # External API endpoint (hwmonDaemon, API-key auth)
|
||||||
|
├── README.md # This file
|
||||||
|
└── index.php # Main router
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow States
|
||||||
|
|
||||||
|
### Default Workflow
|
||||||
|
```
|
||||||
|
Open → Pending → In Progress → Closed
|
||||||
|
↑ ↑
|
||||||
|
└───────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
All states can transition to Closed (with comment).
|
||||||
|
Closed tickets can be reopened to Open or In Progress.
|
||||||
|
|
||||||
|
## Setup & Configuration
|
||||||
|
|
||||||
### 1. Environment Configuration
|
### 1. Environment Configuration
|
||||||
|
|
||||||
Create `.env` file in project root:
|
Copy the example file and edit with your values:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Required environment variables:
|
||||||
```env
|
```env
|
||||||
DB_HOST=10.10.10.50
|
DB_HOST=your_db_host
|
||||||
DB_USER=tinkertickets
|
DB_USER=your_db_user
|
||||||
DB_PASS=your_password
|
DB_PASS=your_password
|
||||||
DB_NAME=ticketing_system
|
DB_NAME=ticketing_system
|
||||||
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
|
APP_DOMAIN=your.domain.example
|
||||||
|
TIMEZONE=America/New_York
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Database Setup
|
Matrix notification variables (all optional):
|
||||||
|
```env
|
||||||
|
# hookshot generic webhook URL — send events to Matrix room
|
||||||
|
MATRIX_WEBHOOK_URL=https://matrix.lotusguild.org/_hookshot/webhook/...
|
||||||
|
|
||||||
Run migrations in order:
|
# Comma-separated Matrix user IDs to @mention on new tickets / status changes
|
||||||
|
MATRIX_NOTIFY_USERS=@jared:matrix.lotusguild.org,@ops:matrix.lotusguild.org
|
||||||
|
|
||||||
|
# Matrix homeserver domain (used to build Matrix user IDs from LLDAP usernames)
|
||||||
|
MATRIX_DOMAIN=matrix.lotusguild.org
|
||||||
|
|
||||||
|
# Synapse internal URL and admin token (used to resolve usernames → Matrix IDs for watcher DMs)
|
||||||
|
SYNAPSE_ADMIN_URL=http://10.10.10.29:8008
|
||||||
|
SYNAPSE_ADMIN_TOKEN=your_synapse_admin_token
|
||||||
|
|
||||||
|
# Optional: send Matrix notification on comments and/or assignments
|
||||||
|
MATRIX_NOTIFY_COMMENTS=0
|
||||||
|
MATRIX_NOTIFY_ASSIGNMENTS=1
|
||||||
|
```
|
||||||
|
|
||||||
|
LDAP/avatar variables (optional):
|
||||||
|
```env
|
||||||
|
LDAP_ENABLED=true
|
||||||
|
LDAP_HOST=10.10.10.39
|
||||||
|
LDAP_PORT=3890
|
||||||
|
LDAP_BIND_DN=uid=tinker-tickets,ou=people,dc=example,dc=com
|
||||||
|
LDAP_BIND_PW=your_bind_password
|
||||||
|
LDAP_BASE_DN=dc=example,dc=com
|
||||||
|
LDAP_USER_BASE=ou=people,dc=example,dc=com
|
||||||
|
AVATAR_CACHE_TTL=3600
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: `APP_DOMAIN` is required for Matrix webhook ticket links to work correctly. Without it, links will default to localhost.
|
||||||
|
|
||||||
|
### 2. Cron Jobs
|
||||||
|
|
||||||
|
Add to crontab for recurring tickets and optional cleanup:
|
||||||
```bash
|
```bash
|
||||||
# Navigate to project directory
|
# Run every hour to create scheduled recurring tickets
|
||||||
cd /root/code/tinker_tickets
|
0 * * * * php /path/to/tinkertickets/cron/create_recurring_tickets.php
|
||||||
|
|
||||||
# Run each migration
|
# Optional: clean up orphaned uploads weekly
|
||||||
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/001_initial_schema.sql
|
0 3 * * 0 php /path/to/tinkertickets/scripts/cleanup_orphan_uploads.php
|
||||||
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/007_add_ticket_assignment.sql
|
|
||||||
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/008_add_status_workflows.sql
|
|
||||||
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/009_add_ticket_templates.sql
|
|
||||||
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/010_add_bulk_operations.sql
|
|
||||||
mysql -h 10.10.10.50 -u tinkertickets -p ticketing_system < migrations/011_remove_view_tracking.sql
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Web Server Configuration
|
### 3. File Uploads
|
||||||
|
|
||||||
**Apache Configuration** (recommended):
|
Ensure the `uploads/` directory exists and is writable:
|
||||||
```apache
|
```bash
|
||||||
<VirtualHost *:80>
|
mkdir -p /path/to/tinkertickets/uploads/avatars
|
||||||
ServerName t.lotusguild.org
|
chown www-data:www-data /path/to/tinkertickets/uploads
|
||||||
DocumentRoot /root/code/tinker_tickets
|
chmod 755 /path/to/tinkertickets/uploads
|
||||||
|
|
||||||
<Directory /root/code/tinker_tickets>
|
|
||||||
Options -Indexes +FollowSymLinks
|
|
||||||
AllowOverride All
|
|
||||||
Require all granted
|
|
||||||
|
|
||||||
# Enable mod_rewrite for clean URLs
|
|
||||||
RewriteEngine On
|
|
||||||
RewriteBase /
|
|
||||||
|
|
||||||
# Route ticket URLs
|
|
||||||
RewriteRule ^ticket/([0-9]+)$ ticket.php?id=$1 [L,QSA]
|
|
||||||
|
|
||||||
# Route ticket create
|
|
||||||
RewriteRule ^ticket/create$ ticket.php?action=create [L,QSA]
|
|
||||||
</Directory>
|
|
||||||
</VirtualHost>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Authelia Integration
|
### 4. Authelia Integration
|
||||||
@@ -147,128 +480,102 @@ Tinker Tickets uses Authelia for SSO. User information is passed via headers:
|
|||||||
- `Remote-Email`: Email address
|
- `Remote-Email`: Email address
|
||||||
- `Remote-Groups`: User groups (comma-separated)
|
- `Remote-Groups`: User groups (comma-separated)
|
||||||
|
|
||||||
Admin users must be in the `admins` group in LLDAP.
|
Admin users must be in the `admin` group in LLDAP.
|
||||||
|
|
||||||
## 📁 Project Structure
|
### 5. hwmonDaemon API Key
|
||||||
|
|
||||||
```
|
1. Go to `/admin/api-keys` and generate a new key named e.g. "hwmonDaemon"
|
||||||
tinker_tickets/
|
2. Copy the displayed key (shown only once)
|
||||||
├── api/ # API endpoints
|
3. On each monitored server, create `/etc/hwmonDaemon/.env`:
|
||||||
│ ├── add_comment.php
|
```env
|
||||||
│ ├── assign_ticket.php
|
TICKET_API_KEY=your_generated_key
|
||||||
│ ├── bulk_operation.php
|
TICKET_API_URL=http://10.10.10.45/create_ticket_api.php
|
||||||
│ ├── get_template.php
|
```
|
||||||
│ ├── get_users.php
|
|
||||||
│ └── update_ticket.php
|
|
||||||
├── assets/ # Static assets
|
|
||||||
│ ├── css/
|
|
||||||
│ │ ├── dashboard.css
|
|
||||||
│ │ └── ticket.css
|
|
||||||
│ └── js/
|
|
||||||
│ ├── dashboard.js
|
|
||||||
│ └── ticket.js
|
|
||||||
├── config/ # Configuration
|
|
||||||
│ └── config.php
|
|
||||||
├── controllers/ # MVC Controllers
|
|
||||||
│ ├── DashboardController.php
|
|
||||||
│ └── TicketController.php
|
|
||||||
├── models/ # Data models
|
|
||||||
│ ├── AuditLogModel.php
|
|
||||||
│ ├── BulkOperationsModel.php
|
|
||||||
│ ├── CommentModel.php
|
|
||||||
│ ├── TemplateModel.php
|
|
||||||
│ ├── TicketModel.php
|
|
||||||
│ ├── UserModel.php
|
|
||||||
│ └── WorkflowModel.php
|
|
||||||
├── views/ # View templates
|
|
||||||
│ ├── CreateTicketView.php
|
|
||||||
│ ├── DashboardView.php
|
|
||||||
│ └── TicketView.php
|
|
||||||
├── migrations/ # Database migrations
|
|
||||||
│ ├── 001_initial_schema.sql
|
|
||||||
│ ├── 007_add_ticket_assignment.sql
|
|
||||||
│ ├── 008_add_status_workflows.sql
|
|
||||||
│ ├── 009_add_ticket_templates.sql
|
|
||||||
│ ├── 010_add_bulk_operations.sql
|
|
||||||
│ └── 011_remove_view_tracking.sql
|
|
||||||
├── index.php # Dashboard entry point
|
|
||||||
├── ticket.php # Ticket view/create entry point
|
|
||||||
└── .env # Environment configuration
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔐 Security Features
|
## Developer Notes
|
||||||
|
|
||||||
- **SQL Injection Prevention**: All queries use prepared statements
|
Key conventions and gotchas for working with this codebase:
|
||||||
- **XSS Protection**: All output is properly escaped with `htmlspecialchars()`
|
|
||||||
- **Session Security**: Secure PHP session handling
|
|
||||||
- **Admin Validation**: Server-side admin checks for privileged operations
|
|
||||||
- **Workflow Enforcement**: Status transitions validated server-side
|
|
||||||
- **Audit Logging**: Complete audit trail of all actions
|
|
||||||
|
|
||||||
## 🎯 Workflow States
|
1. **Session format**: `$_SESSION['user']['user_id']` (not `$_SESSION['user_id']`)
|
||||||
|
2. **API auth check**: Verify `$_SESSION['user']['user_id']` exists
|
||||||
|
3. **Admin check**: `$_SESSION['user']['is_admin'] ?? false`
|
||||||
|
4. **Config path**: `config/config.php` (not `config/db.php`)
|
||||||
|
5. **Comments table**: `ticket_comments` (not `comments`)
|
||||||
|
6. **CSRF**: Required for all POST/DELETE requests via `X-CSRF-Token` header; bootstrap.php rotates token and returns it in `csrf_token` field of all `apiRespond()` responses
|
||||||
|
7. **Cache busting**: `ASSET_VERSION` is auto-computed from asset file mtimes; override with `ASSET_VERSION=` in `.env`
|
||||||
|
8. **Ticket linking**: Use `#123456789` in markdown-enabled comments
|
||||||
|
9. **User groups**: Stored in `users.groups` as comma-separated values
|
||||||
|
10. **API routing**: All API endpoints must be registered in `index.php` router
|
||||||
|
11. **Session in APIs**: `RateLimitMiddleware` starts the session — guard subsequent `session_start()` calls with `if (session_status() === PHP_SESSION_NONE)`
|
||||||
|
12. **Database collation**: Use `utf8mb4_general_ci` (not `unicode_ci`) for new tables
|
||||||
|
13. **Matrix URLs**: Set `APP_DOMAIN` in `.env` for correct ticket URLs in Matrix notifications
|
||||||
|
14. **Ticket ID extraction**: Use `getTicketIdFromUrl()` helper in JS files
|
||||||
|
15. **CSP nonces**: All inline `<script>` tags require `nonce="<?php echo $nonce; ?>"`
|
||||||
|
16. **Visibility validation**: Internal visibility requires at least one group — validated server-side
|
||||||
|
17. **Rate limiting**: Both session-based AND IP-based limits are enforced
|
||||||
|
18. **Relative timestamps**: Dashboard dates use `lt.time.ago()` and refresh every 60 s; full date is always in the `title` attribute for hover
|
||||||
|
19. **Boot sequence**: Shows ASCII banner then boot messages on first page visit per session (`sessionStorage.getItem('booted')`); removed on subsequent loads
|
||||||
|
20. **No raw `fetch()`**: All AJAX calls use `lt.api.get/post/put/delete()` — never use raw `fetch()`. The wrapper auto-adds `Content-Type: application/json` and `X-CSRF-Token`, auto-parses JSON, and throws on non-2xx.
|
||||||
|
21. **Confirm dialogs**: Never use browser `confirm()`. Use `showConfirmModal(title, message, type, onConfirm)` (defined in `utils.js`, available on all pages). Types: `'warning'` | `'error'` | `'info'`.
|
||||||
|
22. **`utils.js` on all pages**: `utils.js` is loaded by all views (including admin). It provides `escapeHtml()`, `getTicketIdFromUrl()`, and `showConfirmModal()`.
|
||||||
|
23. **No `toast.js`**: `toast.js` is deprecated and no longer loaded by any view. Use `lt.toast.success/error/warning/info()` directly from `base.js`.
|
||||||
|
24. **Stats cache**: `StatsModel` caches stats for 60 s. Any API that modifies ticket state must call `(new StatsModel($conn))->invalidateCache()` after changes (bulk_operation, assign_ticket, update_ticket, clone_ticket all do this).
|
||||||
|
25. **External API (`create_ticket_api.php`)**: Uses `ApiKeyAuth` (Bearer token), not session auth. Served directly by the web server from the document root — not through the index.php router. Includes deduplication logic to prevent duplicate hw-alert tickets within 24 h.
|
||||||
|
|
||||||
### Default Workflow
|
## File Reference
|
||||||
```
|
|
||||||
Open → In Progress → Resolved → Closed
|
|
||||||
↓ ↓ ↓
|
|
||||||
└─────────┴──────────┘
|
|
||||||
(can reopen)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Workflow Configuration
|
| File | Purpose |
|
||||||
Status transitions are defined in the `status_transitions` table:
|
|------|---------|
|
||||||
- `from_status`: Current status
|
| `index.php` | Main router for all routes |
|
||||||
- `to_status`: Target status
|
| `create_ticket_api.php` | External API (hwmonDaemon) — Bearer token auth, deduplication |
|
||||||
- `requires_comment`: Whether transition requires a comment
|
| `config/config.php` | Config loader + .env parsing |
|
||||||
- `requires_admin`: Whether transition requires admin privileges
|
| `api/update_ticket.php` | Ticket updates with workflow + visibility validation |
|
||||||
- `is_active`: Whether transition is enabled
|
| `api/notifications.php` | In-app notification bell — reads from audit_log |
|
||||||
|
| `api/user_avatar.php` | LDAP avatar proxy with disk cache |
|
||||||
|
| `api/download_attachment.php` | File downloads with visibility check |
|
||||||
|
| `api/bulk_operation.php` | Bulk operations with visibility filtering |
|
||||||
|
| `models/TicketModel.php` | Ticket CRUD, visibility filtering, collision-safe ID generation |
|
||||||
|
| `models/ApiKeyModel.php` | API key generation and validation |
|
||||||
|
| `models/StatsModel.php` | Dashboard statistics (60 s cache; invalidated on ticket changes) |
|
||||||
|
| `middleware/ApiKeyAuth.php` | Bearer token authentication for external API |
|
||||||
|
| `middleware/AuthMiddleware.php` | Authelia header parsing + session setup |
|
||||||
|
| `middleware/CsrfMiddleware.php` | CSRF token generation and validation |
|
||||||
|
| `middleware/SecurityHeadersMiddleware.php` | CSP headers with per-request nonce generation |
|
||||||
|
| `middleware/RateLimitMiddleware.php` | Session + IP-based rate limiting |
|
||||||
|
| `helpers/NotificationHelper.php` | Matrix hookshot webhook events |
|
||||||
|
| `helpers/SynapseHelper.php` | Username → Matrix ID resolution via Synapse admin API |
|
||||||
|
| `assets/js/dashboard.js` | Dashboard UI, kanban, sidebar, bulk actions, charts, command palette |
|
||||||
|
| `assets/js/ticket.js` | Ticket UI, @mention autocomplete, lightbox, visibility editing |
|
||||||
|
| `assets/js/markdown.js` | Markdown parsing + ticket linking (XSS-safe) |
|
||||||
|
| `assets/css/dashboard.css` | Terminal styling, kanban, sidebar, charts, workload panel |
|
||||||
|
| `assets/css/ticket.css` | Ticket view: SLA progress, attachment thumbnails, timeline |
|
||||||
|
|
||||||
## 📝 Usage Examples
|
## Security Implementations
|
||||||
|
|
||||||
### Creating a Ticket
|
| Feature | Implementation |
|
||||||
1. Click "New Ticket" button
|
|---------|---------------|
|
||||||
2. Select template (optional) - auto-fills common fields
|
| SQL Injection | All queries use prepared statements with parameter binding |
|
||||||
3. Fill in title, description, category, type, priority
|
| XSS Prevention | HTML escaped in markdown parser; CSP with per-request nonces |
|
||||||
4. Click "Create Ticket"
|
| CSRF Protection | Token-based with constant-time comparison (`hash_equals`); rotated on each write |
|
||||||
|
| Session Security | Fixation prevention, secure cookies, session timeout |
|
||||||
|
| Rate Limiting | Session-based + IP-based (file storage) |
|
||||||
|
| File Security | Path traversal prevention, MIME type validation, uploads `.htaccess` blocks execution |
|
||||||
|
| Visibility | Enforced on ticket views, downloads, and bulk operations |
|
||||||
|
| API Key Auth | SHA-256 hashed keys stored in DB; Bearer token auth for external API |
|
||||||
|
|
||||||
### Updating Ticket Status
|
## CI / CD
|
||||||
1. Open ticket
|
|
||||||
2. Click status dropdown (next to priority badge)
|
|
||||||
3. Select allowed status (workflow-validated)
|
|
||||||
4. Confirm if comment is required
|
|
||||||
|
|
||||||
### Assigning Tickets
|
| Workflow | Purpose | Triggers |
|
||||||
1. Open ticket or use dashboard bulk actions
|
|---|---|---|
|
||||||
2. Select user from "Assigned to" dropdown
|
| `lint.yml` (php-lint) | phpcs PSR-12 standard | Every push and PR |
|
||||||
3. Changes are auto-saved
|
| `lint.yml` (js-lint) | ESLint on `assets/js/` | Every push and PR |
|
||||||
|
| `security.yml` | `npm audit --audit-level=high` (not applicable — no runtime npm deps) | — |
|
||||||
|
| `deploy` job in `lint.yml` | Calls deploy webhooks on CT132 (10.10.10.45): `tinker-deploy` (main) or `tinker-beta-deploy` (development) | Push to `main` or `development`, after both lint jobs pass |
|
||||||
|
|
||||||
### Bulk Operations (Admin Only)
|
Branch protection is enabled on `main` — both lint jobs must pass before any PR can merge.
|
||||||
1. Check multiple tickets on dashboard
|
|
||||||
2. Select bulk action (Close, Assign, Change Priority)
|
|
||||||
3. Complete operation
|
|
||||||
4. All actions are logged in audit trail
|
|
||||||
|
|
||||||
## 🔮 Roadmap
|
Lint config: `.phpcs.xml` (PSR-12 with project-specific tweaks), `.eslintrc.json` per directory.
|
||||||
|
|
||||||
- ✅ Activity Timeline
|
## License
|
||||||
- ✅ Ticket Assignment
|
|
||||||
- ✅ Status Transitions with Workflows
|
|
||||||
- ✅ Ticket Templates
|
|
||||||
- ✅ Bulk Actions (Admin Only)
|
|
||||||
- 🎨 **ANSI Art Redesign** (Next Priority)
|
|
||||||
- 🔗 Ticket Dependencies (blocks/blocked by)
|
|
||||||
- 📊 Custom Dashboard Widgets
|
|
||||||
- 🔧 Custom Fields per Category
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
|
||||||
|
|
||||||
This is an internal tool for LotusGuild infrastructure management. For feature requests or bug reports, contact the infrastructure team.
|
|
||||||
|
|
||||||
## 📄 License
|
|
||||||
|
|
||||||
Internal use only - LotusGuild Infrastructure
|
Internal use only - LotusGuild Infrastructure
|
||||||
|
|
||||||
## 🙏 Credits
|
|
||||||
|
|
||||||
Built with ❤️ for the LotusGuild community
|
|
||||||
Powered by PHP, MariaDB, and lots of coffee ☕
|
|
||||||
|
|||||||
+108
-18
@@ -1,8 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// Disable error display in the output
|
// Disable error display in the output
|
||||||
ini_set('display_errors', 0);
|
ini_set('display_errors', 0);
|
||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
// Apply rate limiting
|
||||||
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
// Start output buffering to capture any errors
|
// Start output buffering to capture any errors
|
||||||
ob_start();
|
ob_start();
|
||||||
|
|
||||||
@@ -23,9 +28,15 @@ try {
|
|||||||
require_once $configPath;
|
require_once $configPath;
|
||||||
require_once $commentModelPath;
|
require_once $commentModelPath;
|
||||||
require_once $auditLogModelPath;
|
require_once $auditLogModelPath;
|
||||||
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
|
||||||
|
|
||||||
// Check authentication via session
|
// Check authentication via session
|
||||||
session_start();
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
throw new Exception("Authentication required");
|
throw new Exception("Authentication required");
|
||||||
}
|
}
|
||||||
@@ -45,17 +56,8 @@ try {
|
|||||||
$currentUser = $_SESSION['user'];
|
$currentUser = $_SESSION['user'];
|
||||||
$userId = $currentUser['user_id'];
|
$userId = $currentUser['user_id'];
|
||||||
|
|
||||||
// Create database connection
|
// Use centralized database connection
|
||||||
$conn = new mysqli(
|
$conn = Database::getConnection();
|
||||||
$GLOBALS['config']['DB_HOST'],
|
|
||||||
$GLOBALS['config']['DB_USER'],
|
|
||||||
$GLOBALS['config']['DB_PASS'],
|
|
||||||
$GLOBALS['config']['DB_NAME']
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($conn->connect_error) {
|
|
||||||
throw new Exception("Database connection failed: " . $conn->connect_error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get POST data
|
// Get POST data
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
@@ -64,40 +66,128 @@ try {
|
|||||||
throw new Exception("Invalid JSON data received");
|
throw new Exception("Invalid JSON data received");
|
||||||
}
|
}
|
||||||
|
|
||||||
$ticketId = $data['ticket_id'];
|
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
|
||||||
|
if (!ctype_digit($ticketId) || (int)$ticketId <= 0) {
|
||||||
|
http_response_code(400);
|
||||||
|
ob_end_clean();
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user can access the ticket before allowing a comment
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
$ticket = $ticketModel->getTicketById($ticketId);
|
||||||
|
if (!$ticket) {
|
||||||
|
http_response_code(404);
|
||||||
|
ob_end_clean();
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
if (!$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
|
http_response_code(403);
|
||||||
|
ob_end_clean();
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Access denied']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize models
|
// Initialize models
|
||||||
$commentModel = new CommentModel($conn);
|
$commentModel = new CommentModel($conn);
|
||||||
$auditLog = new AuditLogModel($conn);
|
$auditLog = new AuditLogModel($conn);
|
||||||
|
|
||||||
|
// Extract @mentions from comment text
|
||||||
|
$mentions = $commentModel->extractMentions($data['comment_text'] ?? '');
|
||||||
|
$mentionedUsers = [];
|
||||||
|
if (!empty($mentions)) {
|
||||||
|
$mentionedUsers = $commentModel->getMentionedUsers($mentions);
|
||||||
|
}
|
||||||
|
|
||||||
// Add comment with user tracking
|
// Add comment with user tracking
|
||||||
$result = $commentModel->addComment($ticketId, $data, $userId);
|
$result = $commentModel->addComment($ticketId, $data, $userId);
|
||||||
|
|
||||||
// Log comment creation to audit log
|
// Log comment creation to audit log
|
||||||
if ($result['success'] && isset($result['comment_id'])) {
|
if ($result['success'] && isset($result['comment_id'])) {
|
||||||
$auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId);
|
$auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId);
|
||||||
|
|
||||||
|
// Log mentions to audit log
|
||||||
|
foreach ($mentionedUsers as $mentionedUser) {
|
||||||
|
$auditLog->log(
|
||||||
|
$userId,
|
||||||
|
'mention',
|
||||||
|
'user',
|
||||||
|
(string)$mentionedUser['user_id'],
|
||||||
|
[
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'comment_id' => $result['comment_id'],
|
||||||
|
'mentioned_username' => $mentionedUser['username']
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matrix notifications
|
||||||
|
$authorDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
|
||||||
|
$commentText = $data['comment_text'] ?? '';
|
||||||
|
$ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
|
||||||
|
|
||||||
|
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API
|
||||||
|
if (!empty($mentionedUsers)) {
|
||||||
|
$mentionedUsernames = array_column($mentionedUsers, 'username');
|
||||||
|
$mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames);
|
||||||
|
if (!empty($mentionedMatrixIds)) {
|
||||||
|
NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// General comment notification (opt-in via MATRIX_NOTIFY_COMMENTS)
|
||||||
|
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_COMMENTS'])) {
|
||||||
|
NotificationHelper::sendCommentNotification($ticketId, $ticketTitle, $commentText, $authorDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify watchers of the new comment
|
||||||
|
NotificationHelper::notifyWatchers(
|
||||||
|
$conn,
|
||||||
|
$ticketId,
|
||||||
|
$ticketTitle,
|
||||||
|
'comment_added',
|
||||||
|
['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')],
|
||||||
|
(int)$userId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add mentioned users to result for frontend
|
||||||
|
$result['mentions'] = array_map(function ($u) {
|
||||||
|
return $u['username'];
|
||||||
|
}, $mentionedUsers);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add user display name to result for frontend
|
// Add user info to result for frontend avatar rendering
|
||||||
if ($result['success']) {
|
if ($result['success']) {
|
||||||
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
|
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
|
||||||
|
$result['user_id'] = $userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discard any unexpected output
|
// Discard any unexpected output
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
|
|
||||||
// Return JSON response
|
// Return JSON response
|
||||||
|
if ($result['success']) {
|
||||||
|
http_response_code(201);
|
||||||
|
}
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
// Discard any unexpected output
|
// Discard any unexpected output
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
|
|
||||||
|
// Log error details but don't expose to client
|
||||||
|
error_log("Add comment API error: " . $e->getMessage());
|
||||||
|
|
||||||
// Return error response
|
// Return error response
|
||||||
|
http_response_code(500);
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => $e->getMessage()
|
'error' => 'An internal error occurred'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
+63
-41
@@ -1,55 +1,48 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
|
||||||
require_once dirname(__DIR__) . '/config/config.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||||
header('Content-Type: application/json');
|
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
|
||||||
// Check authentication
|
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
|
||||||
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// CSRF Protection
|
|
||||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
||||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
|
||||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
|
||||||
http_response_code(403);
|
|
||||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$userId = $_SESSION['user']['user_id'];
|
|
||||||
|
|
||||||
// Get request data
|
// Get request data
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
$ticketId = $data['ticket_id'] ?? null;
|
if (!is_array($data)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketIdRaw = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
|
||||||
$assignedTo = $data['assigned_to'] ?? null;
|
$assignedTo = $data['assigned_to'] ?? null;
|
||||||
|
|
||||||
if (!$ticketId) {
|
if (!ctype_digit($ticketIdRaw) || (int)$ticketIdRaw <= 0) {
|
||||||
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Ticket ID required']);
|
echo json_encode(['success' => false, 'error' => 'Ticket ID required']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
$ticketId = $ticketIdRaw;
|
||||||
// Create database connection
|
|
||||||
$conn = new mysqli(
|
|
||||||
$GLOBALS['config']['DB_HOST'],
|
|
||||||
$GLOBALS['config']['DB_USER'],
|
|
||||||
$GLOBALS['config']['DB_PASS'],
|
|
||||||
$GLOBALS['config']['DB_NAME']
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($conn->connect_error) {
|
|
||||||
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$ticketModel = new TicketModel($conn);
|
$ticketModel = new TicketModel($conn);
|
||||||
$auditLogModel = new AuditLogModel($conn);
|
$auditLogModel = new AuditLogModel($conn);
|
||||||
|
$userModel = new UserModel($conn);
|
||||||
|
|
||||||
|
// Verify ticket exists and user can access it
|
||||||
|
$ticket = $ticketModel->getTicketById($ticketId);
|
||||||
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorization: only admins or the ticket creator/assignee can reassign
|
||||||
|
if (!$isAdmin && (int)$ticket['created_by'] !== (int)$userId && (int)$ticket['assigned_to'] !== (int)$userId) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Permission denied']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
if ($assignedTo === null || $assignedTo === '') {
|
if ($assignedTo === null || $assignedTo === '') {
|
||||||
// Unassign ticket
|
// Unassign ticket
|
||||||
@@ -58,13 +51,42 @@ if ($assignedTo === null || $assignedTo === '') {
|
|||||||
$auditLogModel->log($userId, 'unassign', 'ticket', $ticketId);
|
$auditLogModel->log($userId, 'unassign', 'ticket', $ticketId);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Validate assigned_to is a valid user ID
|
||||||
|
$assignedTo = (int)$assignedTo;
|
||||||
|
$targetUser = $userModel->getUserById($assignedTo);
|
||||||
|
if (!$targetUser) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid user ID']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// Assign ticket
|
// Assign ticket
|
||||||
$success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
|
$success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
|
||||||
if ($success) {
|
if ($success) {
|
||||||
$auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]);
|
$auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]);
|
||||||
|
|
||||||
|
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_ASSIGNMENTS'])) {
|
||||||
|
$changedByDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
|
||||||
|
$assigneeName = $targetUser['display_name'] ?? $targetUser['username'] ?? null;
|
||||||
|
$assigneeMatrix = isset($targetUser['username'])
|
||||||
|
? SynapseHelper::resolveUsername($targetUser['username'])
|
||||||
|
: null;
|
||||||
|
NotificationHelper::sendAssignmentNotification(
|
||||||
|
$ticketId,
|
||||||
|
$ticket['title'] ?? "Ticket #{$ticketId}",
|
||||||
|
$assigneeName,
|
||||||
|
$assigneeMatrix,
|
||||||
|
$changedByDisplay
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$conn->close();
|
if (!$success) {
|
||||||
|
http_response_code(500);
|
||||||
echo json_encode(['success' => $success]);
|
apiRespond(['success' => false, 'error' => 'Failed to update ticket assignment']);
|
||||||
|
} else {
|
||||||
|
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
||||||
|
(new StatsModel($conn))->invalidateCache();
|
||||||
|
apiRespond(['success' => true]);
|
||||||
|
}
|
||||||
|
|||||||
+46
-46
@@ -1,45 +1,21 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Audit Log API Endpoint
|
* Audit Log API Endpoint
|
||||||
* Handles fetching filtered audit logs and CSV export
|
* Handles fetching filtered audit logs and CSV export
|
||||||
* Admin-only access
|
* Admin-only access
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/config/config.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
session_start();
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
|
|
||||||
// Check authentication
|
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
|
||||||
http_response_code(401);
|
|
||||||
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check admin status - audit log viewing is admin-only
|
// Check admin status - audit log viewing is admin-only
|
||||||
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
|
||||||
if (!$isAdmin) {
|
if (!$isAdmin) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
echo json_encode(['success' => false, 'error' => 'Admin access required']);
|
echo json_encode(['success' => false, 'error' => 'Admin access required']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create database connection
|
|
||||||
$conn = new mysqli(
|
|
||||||
$GLOBALS['config']['DB_HOST'],
|
|
||||||
$GLOBALS['config']['DB_USER'],
|
|
||||||
$GLOBALS['config']['DB_PASS'],
|
|
||||||
$GLOBALS['config']['DB_NAME']
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($conn->connect_error) {
|
|
||||||
http_response_code(500);
|
|
||||||
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$auditLogModel = new AuditLogModel($conn);
|
$auditLogModel = new AuditLogModel($conn);
|
||||||
|
|
||||||
// GET - Fetch filtered audit logs or export to CSV
|
// GET - Fetch filtered audit logs or export to CSV
|
||||||
@@ -48,13 +24,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
if (isset($_GET['export']) && $_GET['export'] === 'csv') {
|
if (isset($_GET['export']) && $_GET['export'] === 'csv') {
|
||||||
// Build filters
|
// Build filters
|
||||||
$filters = [];
|
$filters = [];
|
||||||
if (isset($_GET['action_type'])) $filters['action_type'] = $_GET['action_type'];
|
if (isset($_GET['action_type'])) {
|
||||||
if (isset($_GET['entity_type'])) $filters['entity_type'] = $_GET['entity_type'];
|
$filters['action_type'] = $_GET['action_type'];
|
||||||
if (isset($_GET['user_id'])) $filters['user_id'] = $_GET['user_id'];
|
}
|
||||||
if (isset($_GET['entity_id'])) $filters['entity_id'] = $_GET['entity_id'];
|
if (isset($_GET['entity_type'])) {
|
||||||
if (isset($_GET['date_from'])) $filters['date_from'] = $_GET['date_from'];
|
$filters['entity_type'] = $_GET['entity_type'];
|
||||||
if (isset($_GET['date_to'])) $filters['date_to'] = $_GET['date_to'];
|
}
|
||||||
if (isset($_GET['ip_address'])) $filters['ip_address'] = $_GET['ip_address'];
|
if (isset($_GET['user_id'])) {
|
||||||
|
$filters['user_id'] = $_GET['user_id'];
|
||||||
|
}
|
||||||
|
if (isset($_GET['entity_id'])) {
|
||||||
|
$filters['entity_id'] = $_GET['entity_id'];
|
||||||
|
}
|
||||||
|
if (isset($_GET['date_from'])) {
|
||||||
|
$filters['date_from'] = $_GET['date_from'];
|
||||||
|
}
|
||||||
|
if (isset($_GET['date_to'])) {
|
||||||
|
$filters['date_to'] = $_GET['date_to'];
|
||||||
|
}
|
||||||
|
if (isset($_GET['ip_address'])) {
|
||||||
|
$filters['ip_address'] = $_GET['ip_address'];
|
||||||
|
}
|
||||||
|
|
||||||
// Get all matching logs (no limit for CSV export)
|
// Get all matching logs (no limit for CSV export)
|
||||||
$result = $auditLogModel->getFilteredLogs($filters, 10000, 0);
|
$result = $auditLogModel->getFilteredLogs($filters, 10000, 0);
|
||||||
@@ -90,26 +80,39 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fclose($output);
|
fclose($output);
|
||||||
$conn->close();
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normal JSON response for filtered logs
|
// Normal JSON response for filtered logs
|
||||||
try {
|
try {
|
||||||
// Get pagination parameters
|
// Get pagination parameters
|
||||||
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||||
$limit = isset($_GET['limit']) ? (int)$_GET['limit'] : 50;
|
$limit = min(500, max(1, (int)($_GET['limit'] ?? 50)));
|
||||||
$offset = ($page - 1) * $limit;
|
$offset = ($page - 1) * $limit;
|
||||||
|
|
||||||
// Build filters
|
// Build filters
|
||||||
$filters = [];
|
$filters = [];
|
||||||
if (isset($_GET['action_type'])) $filters['action_type'] = $_GET['action_type'];
|
if (isset($_GET['action_type'])) {
|
||||||
if (isset($_GET['entity_type'])) $filters['entity_type'] = $_GET['entity_type'];
|
$filters['action_type'] = $_GET['action_type'];
|
||||||
if (isset($_GET['user_id'])) $filters['user_id'] = $_GET['user_id'];
|
}
|
||||||
if (isset($_GET['entity_id'])) $filters['entity_id'] = $_GET['entity_id'];
|
if (isset($_GET['entity_type'])) {
|
||||||
if (isset($_GET['date_from'])) $filters['date_from'] = $_GET['date_from'];
|
$filters['entity_type'] = $_GET['entity_type'];
|
||||||
if (isset($_GET['date_to'])) $filters['date_to'] = $_GET['date_to'];
|
}
|
||||||
if (isset($_GET['ip_address'])) $filters['ip_address'] = $_GET['ip_address'];
|
if (isset($_GET['user_id'])) {
|
||||||
|
$filters['user_id'] = $_GET['user_id'];
|
||||||
|
}
|
||||||
|
if (isset($_GET['entity_id'])) {
|
||||||
|
$filters['entity_id'] = $_GET['entity_id'];
|
||||||
|
}
|
||||||
|
if (isset($_GET['date_from'])) {
|
||||||
|
$filters['date_from'] = $_GET['date_from'];
|
||||||
|
}
|
||||||
|
if (isset($_GET['date_to'])) {
|
||||||
|
$filters['date_to'] = $_GET['date_to'];
|
||||||
|
}
|
||||||
|
if (isset($_GET['ip_address'])) {
|
||||||
|
$filters['ip_address'] = $_GET['ip_address'];
|
||||||
|
}
|
||||||
|
|
||||||
// Get filtered logs
|
// Get filtered logs
|
||||||
$result = $auditLogModel->getFilteredLogs($filters, $limit, $offset);
|
$result = $auditLogModel->getFilteredLogs($filters, $limit, $offset);
|
||||||
@@ -125,12 +128,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'Failed to fetch audit logs']);
|
echo json_encode(['success' => false, 'error' => 'Failed to fetch audit logs']);
|
||||||
}
|
}
|
||||||
$conn->close();
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method not allowed
|
// Method not allowed
|
||||||
http_response_code(405);
|
http_response_code(405);
|
||||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
$conn->close();
|
|
||||||
?>
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Bootstrap - Common setup for API endpoints
|
||||||
|
*
|
||||||
|
* Provides: $conn, $currentUser, $userId, $isAdmin
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* require_once __DIR__ . '/bootstrap.php';
|
||||||
|
* // $conn, $currentUser, $userId, $isAdmin are now available
|
||||||
|
*/
|
||||||
|
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
// Rate limiting (also starts session)
|
||||||
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
|
// Config and database
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
|
||||||
|
// Authentication check
|
||||||
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
http_response_code(401);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF protection for write requests
|
||||||
|
if (in_array($_SERVER['REQUEST_METHOD'], ['POST', 'PUT', 'DELETE'])) {
|
||||||
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
// Rotate token after successful validation; endpoints include it in their JSON response
|
||||||
|
$GLOBALS['_new_csrf_token'] = CsrfMiddleware::rotateToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// Common variables
|
||||||
|
$currentUser = $_SESSION['user'];
|
||||||
|
$userId = $currentUser['user_id'];
|
||||||
|
$isAdmin = $currentUser['is_admin'] ?? false;
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output a JSON response, appending the rotated CSRF token so the
|
||||||
|
* client-side lt.api interceptor can update window.CSRF_TOKEN.
|
||||||
|
*/
|
||||||
|
function apiRespond(array $data): void
|
||||||
|
{
|
||||||
|
if (!empty($GLOBALS['_new_csrf_token'])) {
|
||||||
|
$data['csrf_token'] = $GLOBALS['_new_csrf_token'];
|
||||||
|
}
|
||||||
|
echo json_encode($data);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
+58
-24
@@ -1,6 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
|
||||||
|
// Apply rate limiting
|
||||||
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/config/config.php';
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
require_once dirname(__DIR__) . '/models/BulkOperationsModel.php';
|
require_once dirname(__DIR__) . '/models/BulkOperationsModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
@@ -9,6 +14,7 @@ header('Content-Type: application/json');
|
|||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
http_response_code(401);
|
||||||
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -27,6 +33,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
// Check admin status - bulk operations are admin-only
|
// Check admin status - bulk operations are admin-only
|
||||||
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
||||||
if (!$isAdmin) {
|
if (!$isAdmin) {
|
||||||
|
http_response_code(403);
|
||||||
echo json_encode(['success' => false, 'error' => 'Admin access required']);
|
echo json_encode(['success' => false, 'error' => 'Admin access required']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -38,39 +45,57 @@ $ticketIds = $data['ticket_ids'] ?? [];
|
|||||||
$parameters = $data['parameters'] ?? null;
|
$parameters = $data['parameters'] ?? null;
|
||||||
|
|
||||||
// Validate input
|
// Validate input
|
||||||
if (!$operationType || empty($ticketIds)) {
|
$validOperationTypes = ['bulk_close', 'bulk_assign', 'bulk_priority', 'bulk_status', 'bulk_delete'];
|
||||||
|
if (!$operationType || !in_array($operationType, $validOperationTypes, true) || empty($ticketIds)) {
|
||||||
echo json_encode(['success' => false, 'error' => 'Operation type and ticket IDs required']);
|
echo json_encode(['success' => false, 'error' => 'Operation type and ticket IDs required']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate ticket IDs are integers
|
// Validate ticket IDs: must be non-empty numeric strings (allows leading zeros)
|
||||||
foreach ($ticketIds as $ticketId) {
|
$ticketIds = array_values(array_filter(array_map(function ($id) {
|
||||||
if (!is_numeric($ticketId)) {
|
$s = trim((string)$id);
|
||||||
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID format']);
|
return (ctype_digit($s) && (int)$s > 0) ? $s : null;
|
||||||
exit;
|
}, $ticketIds)));
|
||||||
}
|
if (empty($ticketIds)) {
|
||||||
}
|
echo json_encode(['success' => false, 'error' => 'No valid ticket IDs provided']);
|
||||||
|
|
||||||
// Create database connection
|
|
||||||
$conn = new mysqli(
|
|
||||||
$GLOBALS['config']['DB_HOST'],
|
|
||||||
$GLOBALS['config']['DB_USER'],
|
|
||||||
$GLOBALS['config']['DB_PASS'],
|
|
||||||
$GLOBALS['config']['DB_NAME']
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($conn->connect_error) {
|
|
||||||
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use centralized database connection
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
|
||||||
$bulkOpsModel = new BulkOperationsModel($conn);
|
$bulkOpsModel = new BulkOperationsModel($conn);
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
|
||||||
|
// Verify user can access all tickets in the bulk operation
|
||||||
|
// (Admins can access all, but this is defense-in-depth)
|
||||||
|
$accessibleTicketIds = [];
|
||||||
|
$inaccessibleCount = 0;
|
||||||
|
$tickets = $ticketModel->getTicketsByIds($ticketIds);
|
||||||
|
|
||||||
|
foreach ($ticketIds as $ticketId) {
|
||||||
|
$ticketId = trim($ticketId);
|
||||||
|
$ticket = $tickets[$ticketId] ?? null;
|
||||||
|
|
||||||
|
if ($ticket && $ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
|
||||||
|
$accessibleTicketIds[] = $ticketId;
|
||||||
|
} else {
|
||||||
|
$inaccessibleCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($accessibleTicketIds)) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'No accessible tickets in selection']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use only accessible ticket IDs
|
||||||
|
$ticketIds = $accessibleTicketIds;
|
||||||
|
|
||||||
// Create bulk operation record
|
// Create bulk operation record
|
||||||
$operationId = $bulkOpsModel->createBulkOperation($operationType, $ticketIds, $_SESSION['user']['user_id'], $parameters);
|
$operationId = $bulkOpsModel->createBulkOperation($operationType, $ticketIds, $_SESSION['user']['user_id'], $parameters);
|
||||||
|
|
||||||
if (!$operationId) {
|
if (!$operationId) {
|
||||||
$conn->close();
|
|
||||||
echo json_encode(['success' => false, 'error' => 'Failed to create bulk operation']);
|
echo json_encode(['success' => false, 'error' => 'Failed to create bulk operation']);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -78,19 +103,28 @@ if (!$operationId) {
|
|||||||
// Process the bulk operation
|
// Process the bulk operation
|
||||||
$result = $bulkOpsModel->processBulkOperation($operationId);
|
$result = $bulkOpsModel->processBulkOperation($operationId);
|
||||||
|
|
||||||
$conn->close();
|
|
||||||
|
|
||||||
if (isset($result['error'])) {
|
if (isset($result['error'])) {
|
||||||
|
$conn->close();
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => $result['error']
|
'error' => $result['error']
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
|
// Invalidate stats cache so dashboard tiles reflect changes immediately
|
||||||
|
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
||||||
|
(new StatsModel($conn))->invalidateCache();
|
||||||
|
$conn->close();
|
||||||
|
|
||||||
|
$message = "Bulk operation completed: {$result['processed']} succeeded, {$result['failed']} failed";
|
||||||
|
if ($inaccessibleCount > 0) {
|
||||||
|
$message .= " ($inaccessibleCount skipped - no access)";
|
||||||
|
}
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'operation_id' => $operationId,
|
'operation_id' => $operationId,
|
||||||
'processed' => $result['processed'],
|
'processed' => $result['processed'],
|
||||||
'failed' => $result['failed'],
|
'failed' => $result['failed'],
|
||||||
'message' => "Bulk operation completed: {$result['processed']} succeeded, {$result['failed']} failed"
|
'skipped' => $inaccessibleCount,
|
||||||
|
'message' => $message
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for duplicate tickets API
|
||||||
|
*
|
||||||
|
* Searches for tickets with similar titles using LIKE and SOUNDEX
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
|
||||||
|
// Only accept GET requests
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
ResponseHelper::error('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get title parameter
|
||||||
|
$title = isset($_GET['title']) ? trim($_GET['title']) : '';
|
||||||
|
|
||||||
|
if (strlen($title) < 5) {
|
||||||
|
ResponseHelper::success(['duplicates' => []]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for similar titles
|
||||||
|
// Use both LIKE for substring matching and SOUNDEX for phonetic matching
|
||||||
|
$duplicates = [];
|
||||||
|
|
||||||
|
// Prepare search term for LIKE
|
||||||
|
$searchTerm = '%' . $title . '%';
|
||||||
|
|
||||||
|
// Get SOUNDEX of title
|
||||||
|
$soundexTitle = soundex($title);
|
||||||
|
|
||||||
|
// Build visibility filter so users only see titles they have access to
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
$visFilter = $ticketModel->getVisibilityFilter($currentUser);
|
||||||
|
|
||||||
|
// First, search for exact substring matches (case-insensitive)
|
||||||
|
$sql = "SELECT ticket_id, title, status, priority, created_at
|
||||||
|
FROM tickets
|
||||||
|
WHERE (
|
||||||
|
title LIKE ?
|
||||||
|
OR SOUNDEX(title) = ?
|
||||||
|
)
|
||||||
|
AND status != 'Closed'
|
||||||
|
AND ({$visFilter['sql']})
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 10";
|
||||||
|
|
||||||
|
$types = "ss" . $visFilter['types'];
|
||||||
|
$params = array_merge([$searchTerm, $soundexTitle], $visFilter['params']);
|
||||||
|
$stmt = $conn->prepare($sql);
|
||||||
|
if (!empty($params)) {
|
||||||
|
$stmt->bind_param($types, ...$params);
|
||||||
|
}
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
// Calculate similarity score
|
||||||
|
$similarity = 0;
|
||||||
|
|
||||||
|
// Check for exact substring match
|
||||||
|
if (stripos($row['title'], $title) !== false) {
|
||||||
|
$similarity = 90;
|
||||||
|
// Check SOUNDEX match
|
||||||
|
} elseif (soundex($row['title']) === $soundexTitle) {
|
||||||
|
$similarity = 70;
|
||||||
|
// Check word overlap
|
||||||
|
} else {
|
||||||
|
$titleWords = array_map('strtolower', preg_split('/\s+/', $title));
|
||||||
|
$rowWords = array_map('strtolower', preg_split('/\s+/', $row['title']));
|
||||||
|
$matchingWords = array_intersect($titleWords, $rowWords);
|
||||||
|
$similarity = (count($matchingWords) / max(count($titleWords), 1)) * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($similarity >= 30) {
|
||||||
|
$duplicates[] = [
|
||||||
|
'ticket_id' => $row['ticket_id'],
|
||||||
|
'title' => $row['title'],
|
||||||
|
'status' => $row['status'],
|
||||||
|
'priority' => $row['priority'],
|
||||||
|
'created_at' => $row['created_at'],
|
||||||
|
'similarity' => round($similarity)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
// Sort by similarity descending
|
||||||
|
usort($duplicates, function ($a, $b) {
|
||||||
|
return $b['similarity'] - $a['similarity'];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Limit to top 5
|
||||||
|
$duplicates = array_slice($duplicates, 0, 5);
|
||||||
|
|
||||||
|
ResponseHelper::success(['duplicates' => $duplicates]);
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clone Ticket API
|
||||||
|
* Creates a copy of an existing ticket with the same properties
|
||||||
|
*/
|
||||||
|
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
|
try {
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only accept POST
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get request data
|
||||||
|
$input = file_get_contents('php://input');
|
||||||
|
$data = json_decode($input, true);
|
||||||
|
|
||||||
|
if (!$data || empty($data['ticket_id'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Missing ticket_id']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceTicketIdRaw = trim((string)$data['ticket_id']);
|
||||||
|
if (!ctype_digit($sourceTicketIdRaw) || (int)$sourceTicketIdRaw <= 0) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$sourceTicketId = $sourceTicketIdRaw;
|
||||||
|
$userId = $_SESSION['user']['user_id'];
|
||||||
|
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
||||||
|
|
||||||
|
// Get database connection
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
|
||||||
|
// Get the source ticket
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
$sourceTicket = $ticketModel->getTicketById($sourceTicketId);
|
||||||
|
|
||||||
|
if (!$sourceTicket) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Source ticket not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the user can access this ticket using centralized visibility logic
|
||||||
|
if (!$ticketModel->canUserAccessTicket($sourceTicket, $_SESSION['user'])) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Source ticket not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare cloned ticket data
|
||||||
|
$clonedTicketData = [
|
||||||
|
'title' => '[CLONE] ' . $sourceTicket['title'],
|
||||||
|
'description' => $sourceTicket['description'],
|
||||||
|
'priority' => $sourceTicket['priority'],
|
||||||
|
'category' => $sourceTicket['category'],
|
||||||
|
'type' => $sourceTicket['type'],
|
||||||
|
'visibility' => $sourceTicket['visibility'] ?? 'public',
|
||||||
|
'visibility_groups' => $sourceTicket['visibility_groups'] ?? null
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create the cloned ticket
|
||||||
|
$result = $ticketModel->createTicket($clonedTicketData, $userId);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
// Log the clone operation
|
||||||
|
$auditLog = new AuditLogModel($conn);
|
||||||
|
$auditLog->log($userId, 'create', 'ticket', $result['ticket_id'], [
|
||||||
|
'action' => 'clone',
|
||||||
|
'source_ticket_id' => $sourceTicket['ticket_id'],
|
||||||
|
'title' => $clonedTicketData['title']
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Optionally create a "relates_to" dependency
|
||||||
|
require_once dirname(__DIR__) . '/models/DependencyModel.php';
|
||||||
|
$dependencyModel = new DependencyModel($conn);
|
||||||
|
$dependencyModel->addDependency($result['ticket_id'], $sourceTicket['ticket_id'], 'relates_to', $userId);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
||||||
|
(new StatsModel($conn))->invalidateCache();
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'new_ticket_id' => $result['ticket_id'],
|
||||||
|
'message' => 'Ticket cloned successfully'
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $result['error'] ?? 'Failed to create cloned ticket'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log("Clone ticket API error: " . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom Fields Management API
|
||||||
|
* CRUD operations for custom field definitions
|
||||||
|
*/
|
||||||
|
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
|
try {
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check admin privileges for write operations
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET' && !$_SESSION['user']['is_admin']) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF Protection for write operations
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use centralized database connection
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$model = new CustomFieldModel($conn);
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||||
|
$category = isset($_GET['category']) ? $_GET['category'] : null;
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
case 'GET':
|
||||||
|
if ($id) {
|
||||||
|
$field = $model->getDefinition($id);
|
||||||
|
echo json_encode(['success' => (bool)$field, 'field' => $field]);
|
||||||
|
} else {
|
||||||
|
// Get all definitions, optionally filtered by category
|
||||||
|
$activeOnly = !isset($_GET['include_inactive']);
|
||||||
|
$fields = $model->getAllDefinitions($category, $activeOnly);
|
||||||
|
echo json_encode(['success' => true, 'fields' => $fields]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'POST':
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid JSON']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$result = $model->createDefinition($data);
|
||||||
|
echo json_encode($result);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'PUT':
|
||||||
|
if (!$id) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid JSON']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$result = $model->updateDefinition($id, $data);
|
||||||
|
echo json_encode($result);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'DELETE':
|
||||||
|
if (!$id) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $model->deleteDefinition($id);
|
||||||
|
echo json_encode($result);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log("Custom fields API error: " . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete Attachment API
|
||||||
|
*
|
||||||
|
* Handles deletion of ticket attachments
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Capture errors for debugging
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
// Apply rate limiting (also starts session)
|
||||||
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
|
// Ensure session is started
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
ResponseHelper::unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only accept DELETE or POST requests
|
||||||
|
if (!in_array($_SERVER['REQUEST_METHOD'], ['DELETE', 'POST'])) {
|
||||||
|
ResponseHelper::error('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get request body
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$input = array_merge($_POST, $input ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify CSRF token
|
||||||
|
$csrfToken = $input['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
|
ResponseHelper::forbidden('Invalid CSRF token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get attachment ID
|
||||||
|
$attachmentId = isset($input['attachment_id']) ? (int)$input['attachment_id'] : 0;
|
||||||
|
if ($attachmentId <= 0 || (string)$attachmentId !== (string)($input['attachment_id'] ?? '')) {
|
||||||
|
ResponseHelper::error('Valid attachment ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$attachmentModel = new AttachmentModel(Database::getConnection());
|
||||||
|
|
||||||
|
// Get attachment details
|
||||||
|
$attachment = $attachmentModel->getAttachment($attachmentId);
|
||||||
|
if (!$attachment) {
|
||||||
|
ResponseHelper::notFound('Attachment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user can access the parent ticket
|
||||||
|
$ticketModel = new TicketModel(Database::getConnection());
|
||||||
|
$ticket = $ticketModel->getTicketById($attachment['ticket_id']);
|
||||||
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
|
||||||
|
ResponseHelper::notFound('Attachment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permission (must be uploader or admin)
|
||||||
|
$isAdmin = $_SESSION['user']['is_admin'] ?? false;
|
||||||
|
if (!$attachmentModel->canUserDelete($attachmentId, $_SESSION['user']['user_id'], $isAdmin)) {
|
||||||
|
ResponseHelper::forbidden('You do not have permission to delete this attachment');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the file — use realpath() to prevent path traversal
|
||||||
|
$uploadDir = realpath($GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads');
|
||||||
|
$filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename'];
|
||||||
|
$realPath = realpath($filePath);
|
||||||
|
|
||||||
|
if ($realPath !== false) {
|
||||||
|
// Ensure the resolved path is still inside the upload directory
|
||||||
|
if (strncmp($realPath, $uploadDir . DIRECTORY_SEPARATOR, strlen($uploadDir) + 1) !== 0) {
|
||||||
|
ResponseHelper::forbidden('Access denied');
|
||||||
|
}
|
||||||
|
if (!unlink($realPath)) {
|
||||||
|
ResponseHelper::serverError('Failed to delete file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from database
|
||||||
|
if (!$attachmentModel->deleteAttachment($attachmentId)) {
|
||||||
|
ResponseHelper::serverError('Failed to delete attachment record');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the deletion
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
$auditLog = new AuditLogModel($conn);
|
||||||
|
$auditLog->log(
|
||||||
|
$_SESSION['user']['user_id'],
|
||||||
|
'attachment_delete',
|
||||||
|
'ticket_attachments',
|
||||||
|
(string)$attachmentId,
|
||||||
|
[
|
||||||
|
'ticket_id' => $attachment['ticket_id'],
|
||||||
|
'filename' => $attachment['original_filename'],
|
||||||
|
'size' => $attachment['file_size']
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
ResponseHelper::success([], 'Attachment deleted successfully');
|
||||||
|
} catch (Exception $e) {
|
||||||
|
ResponseHelper::serverError('Failed to delete attachment');
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API endpoint for deleting a comment
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Disable error display in the output
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
// Apply rate limiting
|
||||||
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
|
// Start output buffering
|
||||||
|
ob_start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
|
// Only allow POST or DELETE — reject GET to prevent CSRF bypass
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
if ($method !== 'POST' && $method !== 'DELETE') {
|
||||||
|
http_response_code(405);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check authentication via session
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
throw new Exception("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentUser = $_SESSION['user'];
|
||||||
|
$userId = $currentUser['user_id'];
|
||||||
|
$isAdmin = $currentUser['is_admin'] ?? false;
|
||||||
|
|
||||||
|
// Use centralized database connection
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
|
||||||
|
// Get data - support both POST body and query params
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!$data || !isset($data['comment_id'])) {
|
||||||
|
// Also check POST params (no GET fallback — prevents CSRF bypass via URL)
|
||||||
|
if (isset($_POST['comment_id'])) {
|
||||||
|
$data = ['comment_id' => $_POST['comment_id']];
|
||||||
|
} else {
|
||||||
|
throw new Exception("Missing required field: comment_id");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$commentId = (int)$data['comment_id'];
|
||||||
|
|
||||||
|
// Initialize models
|
||||||
|
$commentModel = new CommentModel($conn);
|
||||||
|
$auditLog = new AuditLogModel($conn);
|
||||||
|
|
||||||
|
// Get comment before deletion for audit log and access check
|
||||||
|
$comment = $commentModel->getCommentById($commentId);
|
||||||
|
|
||||||
|
// Verify user can access the parent ticket
|
||||||
|
if ($comment) {
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
$ticket = $ticketModel->getTicketById($comment['ticket_id']);
|
||||||
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
|
ob_end_clean();
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Access denied']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete comment
|
||||||
|
$result = $commentModel->deleteComment($commentId, $userId, $isAdmin);
|
||||||
|
|
||||||
|
// Log the deletion if successful
|
||||||
|
if ($result['success'] && $comment) {
|
||||||
|
$auditLog->log(
|
||||||
|
$userId,
|
||||||
|
'delete',
|
||||||
|
'comment',
|
||||||
|
(string)$commentId,
|
||||||
|
[
|
||||||
|
'ticket_id' => $comment['ticket_id'],
|
||||||
|
'comment_text_preview' => substr($comment['comment_text'], 0, 100)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discard any unexpected output
|
||||||
|
ob_end_clean();
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode($result);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
ob_end_clean();
|
||||||
|
error_log("Delete comment API error: " . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'An internal error occurred'
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download Attachment API
|
||||||
|
*
|
||||||
|
* Serves file downloads for ticket attachments
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
http_response_code(401);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get attachment ID
|
||||||
|
$attachmentId = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||||||
|
if ($attachmentId <= 0 || (string)$attachmentId !== (string)($_GET['id'] ?? '')) {
|
||||||
|
http_response_code(400);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Valid attachment ID is required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$attachmentModel = new AttachmentModel(Database::getConnection());
|
||||||
|
|
||||||
|
// Get attachment details
|
||||||
|
$attachment = $attachmentModel->getAttachment($attachmentId);
|
||||||
|
if (!$attachment) {
|
||||||
|
http_response_code(404);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Attachment not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the associated ticket exists and user has access
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
$ticket = $ticketModel->getTicketById($attachment['ticket_id']);
|
||||||
|
|
||||||
|
if (!$ticket) {
|
||||||
|
http_response_code(404);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Associated ticket not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has access to this ticket based on visibility settings
|
||||||
|
if (!$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Access denied to this ticket']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$conn->close();
|
||||||
|
|
||||||
|
// Build file path
|
||||||
|
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
|
||||||
|
$filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename'];
|
||||||
|
|
||||||
|
// Security: Verify the resolved path is within the uploads directory (prevent path traversal)
|
||||||
|
$realUploadDir = realpath($uploadDir);
|
||||||
|
$realFilePath = realpath($filePath);
|
||||||
|
|
||||||
|
if ($realFilePath === false || $realUploadDir === false || strpos($realFilePath, $realUploadDir . DIRECTORY_SEPARATOR) !== 0) {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Access denied']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
if (!file_exists($realFilePath)) {
|
||||||
|
http_response_code(404);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'File not found on server']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the validated real path
|
||||||
|
$filePath = $realFilePath;
|
||||||
|
|
||||||
|
// Determine if we should display inline or force download
|
||||||
|
$inline = isset($_GET['inline']) && $_GET['inline'] === '1';
|
||||||
|
$inlineTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain'];
|
||||||
|
|
||||||
|
// Set headers
|
||||||
|
$disposition = ($inline && in_array($attachment['mime_type'], $inlineTypes)) ? 'inline' : 'attachment';
|
||||||
|
|
||||||
|
// Sanitize filename for Content-Disposition
|
||||||
|
$safeFilename = preg_replace('/[^\w\s\-\.]/', '_', $attachment['original_filename']);
|
||||||
|
|
||||||
|
header('Content-Type: ' . $attachment['mime_type']);
|
||||||
|
header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"');
|
||||||
|
header('Content-Length: ' . $attachment['file_size']);
|
||||||
|
header('Cache-Control: private, max-age=3600');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
|
// Prevent PHP from timing out on large files
|
||||||
|
set_time_limit(0);
|
||||||
|
|
||||||
|
// Clear output buffer
|
||||||
|
if (ob_get_level()) {
|
||||||
|
ob_end_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream file
|
||||||
|
$handle = fopen($filePath, 'rb');
|
||||||
|
if ($handle === false) {
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Failed to open file']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (!feof($handle)) {
|
||||||
|
echo fread($handle, 8192);
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
exit;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Failed to download attachment']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export Tickets API
|
||||||
|
*
|
||||||
|
* Exports tickets to CSV format with optional filtering
|
||||||
|
* Respects ticket visibility settings
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Disable error display in the output
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
// Apply rate limiting
|
||||||
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Include required files
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
|
// Check authentication via session
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentUser = $_SESSION['user'];
|
||||||
|
|
||||||
|
// Use centralized database connection
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
|
||||||
|
// Get filter parameters
|
||||||
|
$status = isset($_GET['status']) ? $_GET['status'] : null;
|
||||||
|
$category = isset($_GET['category']) ? $_GET['category'] : null;
|
||||||
|
$type = isset($_GET['type']) ? $_GET['type'] : null;
|
||||||
|
$search = isset($_GET['search']) ? trim($_GET['search']) : null;
|
||||||
|
$format = isset($_GET['format']) ? $_GET['format'] : 'csv';
|
||||||
|
$ticketIds = isset($_GET['ticket_ids']) ? $_GET['ticket_ids'] : null;
|
||||||
|
$singleIdRaw = isset($_GET['ticket_id']) ? trim($_GET['ticket_id']) : null;
|
||||||
|
$singleId = ($singleIdRaw !== null && ctype_digit($singleIdRaw) && (int)$singleIdRaw > 0) ? $singleIdRaw : null;
|
||||||
|
|
||||||
|
// Initialize model
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
|
||||||
|
// Check if specific ticket IDs are provided
|
||||||
|
if ($ticketIds) {
|
||||||
|
// Parse and validate ticket IDs
|
||||||
|
$ticketIdArray = array_filter(array_map('trim', explode(',', $ticketIds)));
|
||||||
|
if (empty($ticketIdArray)) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'No valid ticket IDs provided']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get specific tickets by IDs
|
||||||
|
$allTickets = $ticketModel->getTicketsByIds($ticketIdArray);
|
||||||
|
|
||||||
|
// Filter tickets based on visibility - only export tickets the user can access
|
||||||
|
$tickets = [];
|
||||||
|
foreach ($allTickets as $ticket) {
|
||||||
|
if ($ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
|
$tickets[] = $ticket;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Get all tickets with filters (no pagination for export)
|
||||||
|
// Pass $currentUser so visibility filtering is applied correctly
|
||||||
|
$result = $ticketModel->getAllTickets(1, 10000, $status, 'created_at', 'desc', $category, $type, $search, [], $currentUser);
|
||||||
|
$tickets = $result['tickets'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($format === 'csv') {
|
||||||
|
// CSV Export
|
||||||
|
header('Content-Type: text/csv; charset=utf-8');
|
||||||
|
header('Content-Disposition: attachment; filename="tickets_export_' . date('Y-m-d_His') . '.csv"');
|
||||||
|
header('Cache-Control: no-cache, must-revalidate');
|
||||||
|
header('Pragma: no-cache');
|
||||||
|
|
||||||
|
// Create output stream
|
||||||
|
$output = fopen('php://output', 'w');
|
||||||
|
|
||||||
|
// Add BOM for Excel UTF-8 compatibility
|
||||||
|
fprintf($output, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||||
|
|
||||||
|
// CSV Headers
|
||||||
|
$headers = [
|
||||||
|
'Ticket ID',
|
||||||
|
'Title',
|
||||||
|
'Status',
|
||||||
|
'Priority',
|
||||||
|
'Category',
|
||||||
|
'Type',
|
||||||
|
'Created By',
|
||||||
|
'Assigned To',
|
||||||
|
'Created At',
|
||||||
|
'Updated At',
|
||||||
|
'Description'
|
||||||
|
];
|
||||||
|
fputcsv($output, $headers);
|
||||||
|
|
||||||
|
// CSV Data
|
||||||
|
foreach ($tickets as $ticket) {
|
||||||
|
$row = [
|
||||||
|
$ticket['ticket_id'],
|
||||||
|
$ticket['title'],
|
||||||
|
$ticket['status'],
|
||||||
|
'P' . $ticket['priority'],
|
||||||
|
$ticket['category'],
|
||||||
|
$ticket['type'],
|
||||||
|
$ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System',
|
||||||
|
$ticket['assigned_display_name'] ?? $ticket['assigned_username'] ?? 'Unassigned',
|
||||||
|
$ticket['created_at'],
|
||||||
|
$ticket['updated_at'],
|
||||||
|
$ticket['description']
|
||||||
|
];
|
||||||
|
fputcsv($output, $row);
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($output);
|
||||||
|
exit;
|
||||||
|
} elseif ($format === 'json') {
|
||||||
|
// JSON Export
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
header('Content-Disposition: attachment; filename="tickets_export_' . date('Y-m-d_His') . '.json"');
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'exported_at' => date('c'),
|
||||||
|
'total_tickets' => count($tickets),
|
||||||
|
'tickets' => array_map(function ($t) {
|
||||||
|
return [
|
||||||
|
'ticket_id' => $t['ticket_id'],
|
||||||
|
'title' => $t['title'],
|
||||||
|
'status' => $t['status'],
|
||||||
|
'priority' => $t['priority'],
|
||||||
|
'category' => $t['category'],
|
||||||
|
'type' => $t['type'],
|
||||||
|
'description' => $t['description'],
|
||||||
|
'created_by' => $t['creator_display_name'] ?? $t['creator_username'],
|
||||||
|
'assigned_to' => $t['assigned_display_name'] ?? $t['assigned_username'],
|
||||||
|
'created_at' => $t['created_at'],
|
||||||
|
'updated_at' => $t['updated_at']
|
||||||
|
];
|
||||||
|
}, $tickets)
|
||||||
|
], JSON_PRETTY_PRINT);
|
||||||
|
exit;
|
||||||
|
} elseif ($format === 'full') {
|
||||||
|
// Full single-ticket export: ticket + all comments + audit timeline
|
||||||
|
if (!$singleId) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'format=full requires a ticket_id parameter']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticket = $ticketModel->getTicketById($singleId);
|
||||||
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$commentModel = new CommentModel($conn);
|
||||||
|
$auditLogModel = new AuditLogModel($conn);
|
||||||
|
|
||||||
|
// Load flat comment list (no threading nesting in export)
|
||||||
|
$rawComments = $commentModel->getCommentsByTicketId($ticket['ticket_id'], false);
|
||||||
|
$timeline = $auditLogModel->getTicketTimeline((string)$ticket['ticket_id']);
|
||||||
|
|
||||||
|
$comments = array_map(function ($c) {
|
||||||
|
return [
|
||||||
|
'comment_id' => $c['comment_id'],
|
||||||
|
'author' => $c['display_name'] ?? $c['username'] ?? 'Unknown',
|
||||||
|
'created_at' => $c['created_at'],
|
||||||
|
'updated_at' => $c['updated_at'] ?? null,
|
||||||
|
'comment_text' => $c['comment_text'],
|
||||||
|
'parent_comment_id' => $c['parent_comment_id'] ?? null,
|
||||||
|
];
|
||||||
|
}, $rawComments);
|
||||||
|
|
||||||
|
$timelineOut = array_map(function ($row) {
|
||||||
|
$details = $row['details'];
|
||||||
|
if (is_string($details)) {
|
||||||
|
$details = json_decode($details, true) ?? $details;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'action' => $row['action_type'],
|
||||||
|
'entity' => $row['entity_type'],
|
||||||
|
'actor' => $row['display_name'] ?? $row['username'] ?? 'System',
|
||||||
|
'details' => $details,
|
||||||
|
'created_at' => $row['created_at'],
|
||||||
|
];
|
||||||
|
}, $timeline);
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
header('Content-Disposition: attachment; filename="ticket_' . $ticket['ticket_id'] . '_full_' . date('Y-m-d_His') . '.json"');
|
||||||
|
header('Cache-Control: no-cache, must-revalidate');
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'exported_at' => date('c'),
|
||||||
|
'ticket' => [
|
||||||
|
'ticket_id' => $ticket['ticket_id'],
|
||||||
|
'title' => $ticket['title'],
|
||||||
|
'status' => $ticket['status'],
|
||||||
|
'priority' => 'P' . $ticket['priority'],
|
||||||
|
'category' => $ticket['category'],
|
||||||
|
'type' => $ticket['type'],
|
||||||
|
'visibility' => $ticket['visibility'] ?? 'public',
|
||||||
|
'description' => $ticket['description'],
|
||||||
|
'created_by' => $ticket['creator_display_name'] ?? $ticket['creator_username'] ?? 'System',
|
||||||
|
'assigned_to' => $ticket['assigned_display_name'] ?? $ticket['assigned_username'] ?? 'Unassigned',
|
||||||
|
'created_at' => $ticket['created_at'],
|
||||||
|
'updated_at' => $ticket['updated_at'],
|
||||||
|
'closed_at' => $ticket['closed_at'] ?? null,
|
||||||
|
],
|
||||||
|
'comments' => $comments,
|
||||||
|
'comment_count' => count($comments),
|
||||||
|
'timeline' => $timelineOut,
|
||||||
|
], JSON_PRETTY_PRINT);
|
||||||
|
exit;
|
||||||
|
} else {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid format. Use csv, json, or full.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log("Export tickets API error: " . $e->getMessage());
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'An internal error occurred'
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// API endpoint for generating API keys (Admin only)
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
|
||||||
|
// Apply rate limiting
|
||||||
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load config
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
|
||||||
|
// Load models
|
||||||
|
require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
|
// Check authentication via session
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
throw new Exception("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check admin privileges
|
||||||
|
if (!isset($_SESSION['user']['is_admin']) || !$_SESSION['user']['is_admin']) {
|
||||||
|
throw new Exception("Admin privileges required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
|
http_response_code(403);
|
||||||
|
throw new Exception("Invalid CSRF token");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow POST
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
throw new Exception("Method not allowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get request data
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
if (!$input) {
|
||||||
|
throw new Exception("Invalid request data");
|
||||||
|
}
|
||||||
|
|
||||||
|
$keyName = trim($input['key_name'] ?? '');
|
||||||
|
$expiresInDays = $input['expires_in_days'] ?? null;
|
||||||
|
|
||||||
|
if (empty($keyName)) {
|
||||||
|
throw new Exception("Key name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($keyName) > 100) {
|
||||||
|
throw new Exception("Key name must be 100 characters or less");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate expires_in_days if provided
|
||||||
|
if ($expiresInDays !== null && $expiresInDays !== '') {
|
||||||
|
$expiresInDays = (int)$expiresInDays;
|
||||||
|
if ($expiresInDays < 1 || $expiresInDays > 3650) {
|
||||||
|
throw new Exception("Expiration must be between 1 and 3650 days");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$expiresInDays = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use centralized database connection
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
|
||||||
|
// Generate API key
|
||||||
|
$apiKeyModel = new ApiKeyModel($conn);
|
||||||
|
$result = $apiKeyModel->createKey($keyName, $_SESSION['user']['user_id'], $expiresInDays);
|
||||||
|
|
||||||
|
if (!$result['success']) {
|
||||||
|
throw new Exception($result['error'] ?? "Failed to generate API key");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the action
|
||||||
|
$auditLog = new AuditLogModel($conn);
|
||||||
|
$auditLog->log(
|
||||||
|
$_SESSION['user']['user_id'],
|
||||||
|
'create',
|
||||||
|
'api_key',
|
||||||
|
$result['key_id'],
|
||||||
|
['key_name' => $keyName, 'expires_in_days' => $expiresInDays]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear output buffer
|
||||||
|
ob_end_clean();
|
||||||
|
|
||||||
|
// Return success with the plaintext key (shown only once)
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'api_key' => $result['api_key'],
|
||||||
|
'key_prefix' => $result['key_prefix'],
|
||||||
|
'key_id' => $result['key_id'],
|
||||||
|
'expires_at' => $result['expires_at']
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
ob_end_clean();
|
||||||
|
error_log("Generate API key error: " . $e->getMessage());
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
http_response_code(isset($conn) ? 400 : 500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'An internal error occurred'
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Comments API
|
||||||
|
* Returns paginated comments for a ticket (used by "Load more" on ticket view)
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketId = isset($_GET['ticket_id']) ? (int)$_GET['ticket_id'] : 0;
|
||||||
|
$offset = isset($_GET['offset']) ? max(0, (int)$_GET['offset']) : 0;
|
||||||
|
$limit = isset($_GET['limit']) ? min(100, max(1, (int)$_GET['limit'])) : 50;
|
||||||
|
|
||||||
|
if ($ticketId <= 0) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid ticket_id']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
$ticket = $ticketModel->getTicketById($ticketId);
|
||||||
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$commentModel = new CommentModel($conn);
|
||||||
|
$total = $commentModel->getCommentCount($ticketId);
|
||||||
|
$comments = $commentModel->getCommentsByTicketId($ticketId, true, $limit, $offset);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'comments' => $comments,
|
||||||
|
'total' => $total,
|
||||||
|
'offset' => $offset,
|
||||||
|
'limit' => $limit,
|
||||||
|
'has_more' => ($offset + $limit) < $total,
|
||||||
|
]);
|
||||||
+48
-41
@@ -1,45 +1,52 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
|
||||||
require_once dirname(__DIR__) . '/config/config.php';
|
|
||||||
require_once dirname(__DIR__) . '/models/TemplateModel.php';
|
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
/**
|
||||||
|
* Get Template API
|
||||||
|
* Returns a ticket template by ID
|
||||||
|
*/
|
||||||
|
|
||||||
// Check authentication
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
RateLimitMiddleware::apply('api');
|
||||||
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
|
||||||
exit;
|
require_once dirname(__DIR__) . '/helpers/ErrorHandler.php';
|
||||||
}
|
ErrorHandler::init();
|
||||||
|
|
||||||
// Get template ID from query parameter
|
try {
|
||||||
$templateId = $_GET['template_id'] ?? null;
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
if (!$templateId) {
|
}
|
||||||
echo json_encode(['success' => false, 'error' => 'Template ID required']);
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
exit;
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
}
|
require_once dirname(__DIR__) . '/models/TemplateModel.php';
|
||||||
|
|
||||||
// Create database connection
|
header('Content-Type: application/json');
|
||||||
$conn = new mysqli(
|
|
||||||
$GLOBALS['config']['DB_HOST'],
|
// Check authentication
|
||||||
$GLOBALS['config']['DB_USER'],
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
$GLOBALS['config']['DB_PASS'],
|
ErrorHandler::sendUnauthorizedError('Not authenticated');
|
||||||
$GLOBALS['config']['DB_NAME']
|
}
|
||||||
);
|
|
||||||
|
// Get template ID from query parameter
|
||||||
if ($conn->connect_error) {
|
$templateId = isset($_GET['template_id']) ? (int)$_GET['template_id'] : 0;
|
||||||
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
|
|
||||||
exit;
|
if ($templateId <= 0 || (string)$templateId !== (string)($_GET['template_id'] ?? '')) {
|
||||||
}
|
ErrorHandler::sendValidationError(
|
||||||
|
['template_id' => 'Valid template ID required'],
|
||||||
// Get template
|
'Invalid request'
|
||||||
$templateModel = new TemplateModel($conn);
|
);
|
||||||
$template = $templateModel->getTemplateById($templateId);
|
}
|
||||||
|
|
||||||
$conn->close();
|
// Get template
|
||||||
|
$conn = Database::getConnection();
|
||||||
if ($template) {
|
$templateModel = new TemplateModel($conn);
|
||||||
echo json_encode(['success' => true, 'template' => $template]);
|
$template = $templateModel->getTemplateById($templateId);
|
||||||
} else {
|
|
||||||
echo json_encode(['success' => false, 'error' => 'Template not found']);
|
if ($template) {
|
||||||
|
echo json_encode(['success' => true, 'template' => $template]);
|
||||||
|
} else {
|
||||||
|
ErrorHandler::sendNotFoundError('Template not found');
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
ErrorHandler::log($e->getMessage(), E_ERROR);
|
||||||
|
ErrorHandler::sendErrorResponse('Failed to retrieve template', 500, $e);
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-29
@@ -1,33 +1,32 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
|
||||||
require_once dirname(__DIR__) . '/config/config.php';
|
|
||||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
/**
|
||||||
|
* Get Users API
|
||||||
|
* Returns list of users for @mentions autocomplete
|
||||||
|
*/
|
||||||
|
|
||||||
// Check authentication
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
|
||||||
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
try {
|
||||||
exit;
|
// Get all users for mentions/assignment
|
||||||
|
$result = Database::query("SELECT user_id, username, display_name FROM users ORDER BY display_name, username");
|
||||||
|
|
||||||
|
if (!$result) {
|
||||||
|
throw new Exception("Failed to query users");
|
||||||
|
}
|
||||||
|
|
||||||
|
$users = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$users[] = [
|
||||||
|
'user_id' => $row['user_id'],
|
||||||
|
'username' => $row['username'],
|
||||||
|
'display_name' => $row['display_name']
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'users' => $users]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log("Get users API error: " . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create database connection
|
|
||||||
$conn = new mysqli(
|
|
||||||
$GLOBALS['config']['DB_HOST'],
|
|
||||||
$GLOBALS['config']['DB_USER'],
|
|
||||||
$GLOBALS['config']['DB_PASS'],
|
|
||||||
$GLOBALS['config']['DB_NAME']
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($conn->connect_error) {
|
|
||||||
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all users
|
|
||||||
$userModel = new UserModel($conn);
|
|
||||||
$users = $userModel->getAllUsers();
|
|
||||||
|
|
||||||
$conn->close();
|
|
||||||
|
|
||||||
echo json_encode(['success' => true, 'users' => $users]);
|
|
||||||
|
|||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health Check Endpoint
|
||||||
|
*
|
||||||
|
* Returns system health status for monitoring tools.
|
||||||
|
* Does not require authentication - suitable for load balancer health checks.
|
||||||
|
*
|
||||||
|
* Returns:
|
||||||
|
* - 200 OK: System is healthy
|
||||||
|
* - 503 Service Unavailable: System has issues
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Don't apply rate limiting to health checks - they should always respond
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
header('Cache-Control: no-cache, no-store, must-revalidate');
|
||||||
|
|
||||||
|
$startTime = microtime(true);
|
||||||
|
$checks = [];
|
||||||
|
$healthy = true;
|
||||||
|
|
||||||
|
// Check 1: Database connectivity
|
||||||
|
try {
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
|
||||||
|
// Quick query to verify connection is actually working
|
||||||
|
$result = $conn->query('SELECT 1');
|
||||||
|
if ($result && $result->fetch_row()) {
|
||||||
|
$checks['database'] = [
|
||||||
|
'status' => 'ok',
|
||||||
|
'message' => 'Connected'
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$checks['database'] = [
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Query failed'
|
||||||
|
];
|
||||||
|
$healthy = false;
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$checks['database'] = [
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Connection failed'
|
||||||
|
];
|
||||||
|
$healthy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 2: File system (uploads directory writable)
|
||||||
|
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
|
||||||
|
if (is_dir($uploadDir) && is_writable($uploadDir)) {
|
||||||
|
$checks['filesystem'] = [
|
||||||
|
'status' => 'ok',
|
||||||
|
'message' => 'Writable'
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$checks['filesystem'] = [
|
||||||
|
'status' => 'warning',
|
||||||
|
'message' => 'Upload directory not writable'
|
||||||
|
];
|
||||||
|
// Don't mark as unhealthy - this might be intentional
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 3: Session storage
|
||||||
|
$sessionPath = session_save_path() ?: sys_get_temp_dir();
|
||||||
|
if (is_dir($sessionPath) && is_writable($sessionPath)) {
|
||||||
|
$checks['sessions'] = [
|
||||||
|
'status' => 'ok',
|
||||||
|
'message' => 'Writable'
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$checks['sessions'] = [
|
||||||
|
'status' => 'error',
|
||||||
|
'message' => 'Session storage not writable'
|
||||||
|
];
|
||||||
|
$healthy = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 4: Rate limit storage
|
||||||
|
$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
|
||||||
|
if (!is_dir($rateLimitDir)) {
|
||||||
|
@mkdir($rateLimitDir, 0755, true);
|
||||||
|
}
|
||||||
|
if (is_dir($rateLimitDir) && is_writable($rateLimitDir)) {
|
||||||
|
$checks['rate_limit'] = [
|
||||||
|
'status' => 'ok',
|
||||||
|
'message' => 'Writable'
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$checks['rate_limit'] = [
|
||||||
|
'status' => 'warning',
|
||||||
|
'message' => 'Rate limit storage not writable'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate response time
|
||||||
|
$responseTime = round((microtime(true) - $startTime) * 1000, 2);
|
||||||
|
|
||||||
|
// Set status code
|
||||||
|
http_response_code($healthy ? 200 : 503);
|
||||||
|
|
||||||
|
// Return response
|
||||||
|
echo json_encode([
|
||||||
|
'status' => $healthy ? 'healthy' : 'unhealthy',
|
||||||
|
'timestamp' => date('c'),
|
||||||
|
'response_time_ms' => $responseTime,
|
||||||
|
'checks' => $checks,
|
||||||
|
'version' => '1.0.0'
|
||||||
|
], JSON_PRETTY_PRINT);
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recurring Tickets Management API
|
||||||
|
* CRUD operations for recurring_tickets table
|
||||||
|
*/
|
||||||
|
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
|
try {
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/RecurringTicketModel.php';
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check admin privileges
|
||||||
|
if (!$_SESSION['user']['is_admin']) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentUserId = $_SESSION['user']['user_id'];
|
||||||
|
|
||||||
|
// CSRF Protection for write operations
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use centralized database connection
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$model = new RecurringTicketModel($conn);
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||||
|
$action = isset($_GET['action']) ? $_GET['action'] : null;
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
case 'GET':
|
||||||
|
if ($id) {
|
||||||
|
$recurring = $model->getById($id);
|
||||||
|
echo json_encode(['success' => (bool)$recurring, 'recurring' => $recurring]);
|
||||||
|
} else {
|
||||||
|
$all = $model->getAll(true);
|
||||||
|
echo json_encode(['success' => true, 'recurring_tickets' => $all]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'POST':
|
||||||
|
if ($action === 'toggle' && $id) {
|
||||||
|
$result = $model->toggleActive($id);
|
||||||
|
echo json_encode($result);
|
||||||
|
} else {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
if (!is_array($data) || empty($data['schedule_type']) || empty($data['title_template'])) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'schedule_type and title_template are required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate next run time
|
||||||
|
$nextRun = calculateNextRun(
|
||||||
|
$data['schedule_type'],
|
||||||
|
$data['schedule_day'] ?? null,
|
||||||
|
$data['schedule_time'] ?? '09:00'
|
||||||
|
);
|
||||||
|
|
||||||
|
$data['next_run_at'] = $nextRun;
|
||||||
|
$data['is_active'] = isset($data['is_active']) ? (int)$data['is_active'] : 1;
|
||||||
|
$data['created_by'] = $currentUserId;
|
||||||
|
|
||||||
|
$result = $model->create($data);
|
||||||
|
echo json_encode($result);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'PUT':
|
||||||
|
if (!$id) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
if (!is_array($data) || empty($data['schedule_type'])) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid request data']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate next run time if schedule changed
|
||||||
|
$nextRun = calculateNextRun(
|
||||||
|
$data['schedule_type'],
|
||||||
|
$data['schedule_day'] ?? null,
|
||||||
|
$data['schedule_time'] ?? '09:00'
|
||||||
|
);
|
||||||
|
$data['next_run_at'] = $nextRun;
|
||||||
|
$data['is_active'] = isset($data['is_active']) ? (int)$data['is_active'] : 1;
|
||||||
|
|
||||||
|
$result = $model->update($id, $data);
|
||||||
|
echo json_encode($result);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'DELETE':
|
||||||
|
if (!$id) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $model->delete($id);
|
||||||
|
echo json_encode($result);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log("Recurring tickets API error: " . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateNextRun($scheduleType, $scheduleDay, $scheduleTime)
|
||||||
|
{
|
||||||
|
$now = new DateTime();
|
||||||
|
$time = $scheduleTime ?: '09:00';
|
||||||
|
|
||||||
|
switch ($scheduleType) {
|
||||||
|
case 'daily':
|
||||||
|
$next = new DateTime('tomorrow ' . $time);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'weekly':
|
||||||
|
$days = [1 => 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||||
|
$dayName = $days[(int)$scheduleDay] ?? 'Monday';
|
||||||
|
$next = new DateTime("next {$dayName} " . $time);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'monthly':
|
||||||
|
$day = max(1, min(31, (int)$scheduleDay));
|
||||||
|
$next = new DateTime();
|
||||||
|
$next->modify('first day of next month');
|
||||||
|
// Clamp to last day of target month (handles Feb, 30-day months)
|
||||||
|
$daysInMonth = (int)$next->format('t');
|
||||||
|
$day = min($day, $daysInMonth);
|
||||||
|
$next->setDate((int)$next->format('Y'), (int)$next->format('m'), $day);
|
||||||
|
$parts = explode(':', $time . ':00'); // ensure at least H:M
|
||||||
|
$next->setTime((int)$parts[0], (int)$parts[1], 0);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
$next = new DateTime('tomorrow ' . $time);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next->format('Y-m-d H:i:s');
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template Management API
|
||||||
|
* CRUD operations for ticket_templates table
|
||||||
|
*/
|
||||||
|
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
|
try {
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check admin privileges for write operations
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET' && !$_SESSION['user']['is_admin']) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF Protection for write operations
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use centralized database connection
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
case 'GET':
|
||||||
|
if ($id) {
|
||||||
|
// Get single template
|
||||||
|
$stmt = $conn->prepare("SELECT * FROM ticket_templates WHERE template_id = ?");
|
||||||
|
$stmt->bind_param('i', $id);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
$template = $result->fetch_assoc();
|
||||||
|
$stmt->close();
|
||||||
|
echo json_encode(['success' => true, 'template' => $template]);
|
||||||
|
} else {
|
||||||
|
// Get all templates
|
||||||
|
$result = $conn->query("SELECT * FROM ticket_templates ORDER BY template_name");
|
||||||
|
$templates = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$templates[] = $row;
|
||||||
|
}
|
||||||
|
echo json_encode(['success' => true, 'templates' => $templates]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'POST':
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
// Validate required fields and lengths
|
||||||
|
$templateName = trim($data['template_name'] ?? '');
|
||||||
|
$titleTemplate = trim($data['title_template'] ?? '');
|
||||||
|
if (!$templateName || mb_strlen($templateName) > 100) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Template name is required (max 100 chars)']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
if (!$titleTemplate || mb_strlen($titleTemplate) > 255) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Title template is required (max 255 chars)']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$allowedCategories = ['General','Hardware','Software','Network','Security'];
|
||||||
|
$allowedTypes = ['Issue','Maintenance','Install','Task','Upgrade','Problem'];
|
||||||
|
$category = in_array($data['category'] ?? '', $allowedCategories) ? $data['category'] : 'General';
|
||||||
|
$type = in_array($data['type'] ?? '', $allowedTypes) ? $data['type'] : 'Issue';
|
||||||
|
$priority = max(1, min(5, (int)($data['default_priority'] ?? 4)));
|
||||||
|
$isActive = $data['is_active'] ? 1 : 0;
|
||||||
|
$description = mb_substr($data['description_template'] ?? '', 0, 65535);
|
||||||
|
|
||||||
|
$stmt = $conn->prepare("INSERT INTO ticket_templates
|
||||||
|
(template_name, title_template, description_template, category, type, default_priority, is_active)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)");
|
||||||
|
$stmt->bind_param(
|
||||||
|
'sssssii',
|
||||||
|
$templateName,
|
||||||
|
$titleTemplate,
|
||||||
|
$description,
|
||||||
|
$category,
|
||||||
|
$type,
|
||||||
|
$priority,
|
||||||
|
$isActive
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($stmt->execute()) {
|
||||||
|
echo json_encode(['success' => true, 'template_id' => $conn->insert_id]);
|
||||||
|
} else {
|
||||||
|
error_log("Template creation failed: " . $stmt->error);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Failed to create template']);
|
||||||
|
}
|
||||||
|
$stmt->close();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'PUT':
|
||||||
|
if (!$id) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
// Validate required fields and lengths
|
||||||
|
$templateName = trim($data['template_name'] ?? '');
|
||||||
|
$titleTemplate = trim($data['title_template'] ?? '');
|
||||||
|
if (!$templateName || mb_strlen($templateName) > 100) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Template name is required (max 100 chars)']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
if (!$titleTemplate || mb_strlen($titleTemplate) > 255) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Title template is required (max 255 chars)']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$allowedCategories = ['General','Hardware','Software','Network','Security'];
|
||||||
|
$allowedTypes = ['Issue','Maintenance','Install','Task','Upgrade','Problem'];
|
||||||
|
$category = in_array($data['category'] ?? '', $allowedCategories) ? $data['category'] : 'General';
|
||||||
|
$type = in_array($data['type'] ?? '', $allowedTypes) ? $data['type'] : 'Issue';
|
||||||
|
$priority = max(1, min(5, (int)($data['default_priority'] ?? 4)));
|
||||||
|
$isActive = $data['is_active'] ? 1 : 0;
|
||||||
|
$description = mb_substr($data['description_template'] ?? '', 0, 65535);
|
||||||
|
|
||||||
|
$stmt = $conn->prepare("UPDATE ticket_templates SET
|
||||||
|
template_name = ?, title_template = ?, description_template = ?,
|
||||||
|
category = ?, type = ?, default_priority = ?, is_active = ?
|
||||||
|
WHERE template_id = ?");
|
||||||
|
$stmt->bind_param(
|
||||||
|
'sssssiii',
|
||||||
|
$templateName,
|
||||||
|
$titleTemplate,
|
||||||
|
$description,
|
||||||
|
$category,
|
||||||
|
$type,
|
||||||
|
$priority,
|
||||||
|
$isActive,
|
||||||
|
$id
|
||||||
|
);
|
||||||
|
|
||||||
|
echo json_encode(['success' => $stmt->execute()]);
|
||||||
|
$stmt->close();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'DELETE':
|
||||||
|
if (!$id) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $conn->prepare("DELETE FROM ticket_templates WHERE template_id = ?");
|
||||||
|
$stmt->bind_param('i', $id);
|
||||||
|
echo json_encode(['success' => $stmt->execute()]);
|
||||||
|
$stmt->close();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log("Template API error: " . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workflow/Status Transitions Management API
|
||||||
|
* CRUD operations for status_transitions table
|
||||||
|
*/
|
||||||
|
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
|
try {
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Authentication required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check admin privileges
|
||||||
|
if (!$_SESSION['user']['is_admin']) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Admin privileges required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF Protection for write operations
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use centralized database connection
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
|
||||||
|
// Initialize audit log
|
||||||
|
$auditLog = new AuditLogModel($conn);
|
||||||
|
$userId = $_SESSION['user']['user_id'];
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
case 'GET':
|
||||||
|
if ($id) {
|
||||||
|
// Get single transition
|
||||||
|
$stmt = $conn->prepare("SELECT * FROM status_transitions WHERE transition_id = ?");
|
||||||
|
$stmt->bind_param('i', $id);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
$transition = $result->fetch_assoc();
|
||||||
|
$stmt->close();
|
||||||
|
echo json_encode(['success' => true, 'transition' => $transition]);
|
||||||
|
} else {
|
||||||
|
// Get all transitions
|
||||||
|
$result = $conn->query("SELECT * FROM status_transitions ORDER BY from_status, to_status");
|
||||||
|
$transitions = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$transitions[] = $row;
|
||||||
|
}
|
||||||
|
echo json_encode(['success' => true, 'transitions' => $transitions]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'POST':
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (($data['from_status'] ?? '') === ($data['to_status'] ?? '')) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'From Status and To Status cannot be the same']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $conn->prepare("INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin, is_active)
|
||||||
|
VALUES (?, ?, ?, ?, ?)");
|
||||||
|
$wf_from = $data['from_status'];
|
||||||
|
$wf_to = $data['to_status'];
|
||||||
|
$wf_comment = (int)($data['requires_comment'] ?? 0);
|
||||||
|
$wf_admin = (int)($data['requires_admin'] ?? 0);
|
||||||
|
$wf_active = (int)($data['is_active'] ?? 1);
|
||||||
|
$stmt->bind_param('ssiii', $wf_from, $wf_to, $wf_comment, $wf_admin, $wf_active);
|
||||||
|
|
||||||
|
if ($stmt->execute()) {
|
||||||
|
$transitionId = $conn->insert_id;
|
||||||
|
WorkflowModel::clearCache(); // Clear workflow cache
|
||||||
|
|
||||||
|
// Audit log: workflow transition created
|
||||||
|
$auditLog->log($userId, 'create', 'workflow_transition', (string)$transitionId, [
|
||||||
|
'from_status' => $data['from_status'],
|
||||||
|
'to_status' => $data['to_status'],
|
||||||
|
'requires_comment' => $data['requires_comment'] ?? 0,
|
||||||
|
'requires_admin' => $data['requires_admin'] ?? 0
|
||||||
|
]);
|
||||||
|
|
||||||
|
echo json_encode(['success' => true, 'transition_id' => $transitionId]);
|
||||||
|
} else {
|
||||||
|
error_log("Workflow creation failed: " . $stmt->error);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Failed to create workflow transition']);
|
||||||
|
}
|
||||||
|
$stmt->close();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'PUT':
|
||||||
|
if (!$id) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (($data['from_status'] ?? '') === ($data['to_status'] ?? '')) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'From Status and To Status cannot be the same']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $conn->prepare("UPDATE status_transitions SET
|
||||||
|
from_status = ?, to_status = ?, requires_comment = ?, requires_admin = ?, is_active = ?
|
||||||
|
WHERE transition_id = ?");
|
||||||
|
$wf_from = $data['from_status'];
|
||||||
|
$wf_to = $data['to_status'];
|
||||||
|
$wf_comment = (int)($data['requires_comment'] ?? 0);
|
||||||
|
$wf_admin = (int)($data['requires_admin'] ?? 0);
|
||||||
|
$wf_active = (int)($data['is_active'] ?? 1);
|
||||||
|
$stmt->bind_param('ssiiii', $wf_from, $wf_to, $wf_comment, $wf_admin, $wf_active, $id);
|
||||||
|
|
||||||
|
$success = $stmt->execute();
|
||||||
|
if ($success) {
|
||||||
|
WorkflowModel::clearCache(); // Clear workflow cache
|
||||||
|
|
||||||
|
// Audit log: workflow transition updated
|
||||||
|
$auditLog->log($userId, 'update', 'workflow_transition', (string)$id, [
|
||||||
|
'from_status' => $data['from_status'],
|
||||||
|
'to_status' => $data['to_status'],
|
||||||
|
'requires_comment' => $data['requires_comment'] ?? 0,
|
||||||
|
'requires_admin' => $data['requires_admin'] ?? 0
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
echo json_encode(['success' => $success]);
|
||||||
|
$stmt->close();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'DELETE':
|
||||||
|
if (!$id) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'ID required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get transition details before deletion for audit log
|
||||||
|
$getStmt = $conn->prepare("SELECT from_status, to_status FROM status_transitions WHERE transition_id = ?");
|
||||||
|
$getStmt->bind_param('i', $id);
|
||||||
|
$getStmt->execute();
|
||||||
|
$getResult = $getStmt->get_result();
|
||||||
|
$transitionData = $getResult->fetch_assoc();
|
||||||
|
$getStmt->close();
|
||||||
|
|
||||||
|
$stmt = $conn->prepare("DELETE FROM status_transitions WHERE transition_id = ?");
|
||||||
|
$stmt->bind_param('i', $id);
|
||||||
|
$success = $stmt->execute();
|
||||||
|
if ($success) {
|
||||||
|
WorkflowModel::clearCache(); // Clear workflow cache
|
||||||
|
|
||||||
|
// Audit log: workflow transition deleted
|
||||||
|
$auditLog->log($userId, 'delete', 'workflow_transition', (string)$id, [
|
||||||
|
'from_status' => $transitionData['from_status'] ?? 'unknown',
|
||||||
|
'to_status' => $transitionData['to_status'] ?? 'unknown'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
echo json_encode(['success' => $success]);
|
||||||
|
$stmt->close();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log("Workflow API error: " . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'An internal error occurred']);
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifications API
|
||||||
|
*
|
||||||
|
* GET → returns recent notifications for the current user (last 7 days, max 30)
|
||||||
|
* POST { action: 'mark_read', log_id?: N } → updates last_seen timestamp in user_preferences
|
||||||
|
*
|
||||||
|
* Notifications are derived from audit_log:
|
||||||
|
* - Tickets assigned to me (action_type='assign', details.assigned_to = userId)
|
||||||
|
* - Comments on my tickets (action_type='comment', ticket assigned_to/created_by = userId)
|
||||||
|
* - Status changes on watched (via ticket_watchers)
|
||||||
|
* - @mentions in comments (action_type='comment', details.mentions[] contains username)
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
|
||||||
|
|
||||||
|
$prefsModel = new UserPreferencesModel($conn);
|
||||||
|
|
||||||
|
// ── POST: mark all read (update last_seen timestamp) ──────────────
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
if (($data['action'] ?? '') === 'mark_read') {
|
||||||
|
$prefsModel->setPreference($userId, 'notif_last_seen', date('Y-m-d H:i:s'));
|
||||||
|
apiRespond(['success' => true]);
|
||||||
|
} else {
|
||||||
|
http_response_code(400);
|
||||||
|
apiRespond(['success' => false, 'error' => 'Unknown action']);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET: fetch notifications ──────────────────────────────────────
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
http_response_code(405);
|
||||||
|
apiRespond(['success' => false, 'error' => 'Method not allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get last_seen timestamp (when user last marked all read)
|
||||||
|
$prefs = $prefsModel->getUserPreferences($userId);
|
||||||
|
$lastSeen = $prefs['notif_last_seen'] ?? null;
|
||||||
|
|
||||||
|
// Username for @mention detection
|
||||||
|
$myUsername = $currentUser['username'] ?? '';
|
||||||
|
|
||||||
|
// Query 1: Tickets assigned to me (events from other users)
|
||||||
|
$assignSql = "SELECT
|
||||||
|
al.audit_id AS log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
||||||
|
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
|
WHERE al.action_type = 'assign'
|
||||||
|
AND al.entity_type = 'ticket'
|
||||||
|
AND al.user_id != ?
|
||||||
|
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||||
|
AND al.details LIKE ?
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT 15";
|
||||||
|
|
||||||
|
$assignLike = '%"assigned_to":' . $userId . '%';
|
||||||
|
$stmt = $conn->prepare($assignSql);
|
||||||
|
$stmt->bind_param('is', $userId, $assignLike);
|
||||||
|
$stmt->execute();
|
||||||
|
$assignRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
// Query 2: Comments on tickets I own or watch (events from other users)
|
||||||
|
// Comments are logged as action_type='create', entity_type='comment', ticket_id stored in details JSON.
|
||||||
|
// Avoid JSON_EXTRACT (not universally supported) — fetch recent entries then filter in PHP.
|
||||||
|
|
||||||
|
// Step A: ticket IDs the current user owns or watches
|
||||||
|
$myTicketIds = [];
|
||||||
|
$myTicketsSql = "SELECT DISTINCT ticket_id FROM tickets WHERE assigned_to = ? OR created_by = ?";
|
||||||
|
$stmt = $conn->prepare($myTicketsSql);
|
||||||
|
$stmt->bind_param('ii', $userId, $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$mtResult = $stmt->get_result();
|
||||||
|
while ($mtRow = $mtResult->fetch_assoc()) {
|
||||||
|
$myTicketIds[(int)$mtRow['ticket_id']] = true;
|
||||||
|
$myTicketIds[$mtRow['ticket_id']] = true;
|
||||||
|
}
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
$watchedSql = "SELECT ticket_id FROM ticket_watchers WHERE user_id = ?";
|
||||||
|
$stmt = $conn->prepare($watchedSql);
|
||||||
|
$stmt->bind_param('i', $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$wResult = $stmt->get_result();
|
||||||
|
while ($wRow = $wResult->fetch_assoc()) {
|
||||||
|
$myTicketIds[(int)$wRow['ticket_id']] = true;
|
||||||
|
$myTicketIds[$wRow['ticket_id']] = true;
|
||||||
|
}
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
// Step B: fetch recent comment audit events not by the current user
|
||||||
|
$commentSql = "SELECT
|
||||||
|
al.audit_id AS log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
||||||
|
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
|
WHERE al.action_type IN ('comment', 'create')
|
||||||
|
AND al.entity_type = 'comment'
|
||||||
|
AND al.user_id != ?
|
||||||
|
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT 50";
|
||||||
|
|
||||||
|
$stmt = $conn->prepare($commentSql);
|
||||||
|
$stmt->bind_param('i', $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$rawCommentRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
// Step C: filter to only comments on tickets the current user owns/watches
|
||||||
|
$commentRows = [];
|
||||||
|
foreach ($rawCommentRows as $rawRow) {
|
||||||
|
$d = json_decode($rawRow['details'] ?? '{}', true) ?? [];
|
||||||
|
$tidRaw = $d['ticket_id'] ?? 0;
|
||||||
|
$tid = (int)$tidRaw;
|
||||||
|
if ($tid > 0 && (isset($myTicketIds[$tid]) || isset($myTicketIds[$tidRaw]))) {
|
||||||
|
$commentRows[] = $rawRow;
|
||||||
|
if (count($commentRows) >= 15) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query 3: Status changes on watched tickets (from other users)
|
||||||
|
$statusSql = "SELECT DISTINCT
|
||||||
|
al.audit_id AS log_id, al.action_type, al.entity_type, al.entity_id, al.details, al.created_at,
|
||||||
|
COALESCE(u.display_name, u.username, 'System') AS actor_name
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
|
INNER JOIN ticket_watchers tw ON tw.ticket_id = CAST(al.entity_id AS UNSIGNED) AND tw.user_id = ?
|
||||||
|
WHERE al.action_type = 'update'
|
||||||
|
AND al.entity_type = 'ticket'
|
||||||
|
AND al.user_id != ?
|
||||||
|
AND al.created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||||
|
AND al.details LIKE '%\"status\":%'
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT 10";
|
||||||
|
|
||||||
|
$stmt = $conn->prepare($statusSql);
|
||||||
|
$stmt->bind_param('ii', $userId, $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$statusRows = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
// Merge, deduplicate by log_id, sort by created_at desc
|
||||||
|
$all = [];
|
||||||
|
$seen = [];
|
||||||
|
foreach (array_merge($assignRows, $commentRows, $statusRows) as $row) {
|
||||||
|
$id = (int)$row['log_id'];
|
||||||
|
if (isset($seen[$id])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen[$id] = true;
|
||||||
|
$all[] = $row;
|
||||||
|
}
|
||||||
|
usort($all, fn($a, $b) => strcmp($b['created_at'], $a['created_at']));
|
||||||
|
$all = array_slice($all, 0, 30);
|
||||||
|
|
||||||
|
// Format for response
|
||||||
|
$notifications = [];
|
||||||
|
foreach ($all as $row) {
|
||||||
|
$details = json_decode($row['details'] ?? '{}', true) ?? [];
|
||||||
|
// Comment rows: entity_id is the comment_id; real ticket_id is in details
|
||||||
|
$actionType = ($row['action_type'] === 'create' && $row['entity_type'] === 'comment')
|
||||||
|
? 'comment'
|
||||||
|
: $row['action_type'];
|
||||||
|
$ticketId = ($actionType === 'comment')
|
||||||
|
? ($details['ticket_id'] ?? 0)
|
||||||
|
: $row['entity_id'];
|
||||||
|
$isRead = $lastSeen && $row['created_at'] <= $lastSeen;
|
||||||
|
|
||||||
|
// Build human-readable title
|
||||||
|
$title = match ($actionType) {
|
||||||
|
'assign' => "{$row['actor_name']} assigned ticket #{$ticketId} to you",
|
||||||
|
'comment' => "{$row['actor_name']} commented on ticket #{$ticketId}",
|
||||||
|
'update' => (function () use ($row, $details, $ticketId) {
|
||||||
|
// logTicketUpdate stores delta as {"status": {"from": "Open", "to": "In Progress"}}
|
||||||
|
$from = $details['status']['from'] ?? ($details['old_value'] ?? '?');
|
||||||
|
$to = $details['status']['to'] ?? ($details['new_value'] ?? '?');
|
||||||
|
return "{$row['actor_name']} changed status on #{$ticketId}: {$from} → {$to}";
|
||||||
|
})(),
|
||||||
|
default => "{$row['actor_name']} updated ticket #{$ticketId}",
|
||||||
|
};
|
||||||
|
|
||||||
|
$ticketTitle = $details['title'] ?? null;
|
||||||
|
if ($ticketTitle) {
|
||||||
|
$title .= ' — ' . mb_substr($ticketTitle, 0, 40) . (mb_strlen($ticketTitle) > 40 ? '…' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$notifications[] = [
|
||||||
|
'log_id' => (int)$row['log_id'],
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'title' => $title,
|
||||||
|
'created_at' => $row['created_at'],
|
||||||
|
'is_read' => $isRead,
|
||||||
|
'action' => $actionType,
|
||||||
|
'url' => "/ticket/{$ticketId}",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$unreadCount = count(array_filter($notifications, fn($n) => !$n['is_read']));
|
||||||
|
|
||||||
|
apiRespond([
|
||||||
|
'success' => true,
|
||||||
|
'notifications' => $notifications,
|
||||||
|
'unread_count' => $unreadCount,
|
||||||
|
'last_seen' => $lastSeen,
|
||||||
|
]);
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// API endpoint for revoking API keys (Admin only)
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
|
||||||
|
// Apply rate limiting
|
||||||
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load config
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
|
||||||
|
// Load models
|
||||||
|
require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
|
// Check authentication via session
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
throw new Exception("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check admin privileges
|
||||||
|
if (!isset($_SESSION['user']['is_admin']) || !$_SESSION['user']['is_admin']) {
|
||||||
|
throw new Exception("Admin privileges required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
|
http_response_code(403);
|
||||||
|
throw new Exception("Invalid CSRF token");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow POST
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
throw new Exception("Method not allowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get request data
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
if (!$input) {
|
||||||
|
throw new Exception("Invalid request data");
|
||||||
|
}
|
||||||
|
|
||||||
|
$keyId = (int)($input['key_id'] ?? 0);
|
||||||
|
|
||||||
|
if ($keyId <= 0) {
|
||||||
|
throw new Exception("Valid key ID is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use centralized database connection
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
|
||||||
|
// Get key info for audit log
|
||||||
|
$apiKeyModel = new ApiKeyModel($conn);
|
||||||
|
$keyInfo = $apiKeyModel->getKeyById($keyId);
|
||||||
|
|
||||||
|
if (!$keyInfo) {
|
||||||
|
throw new Exception("API key not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$keyInfo['is_active']) {
|
||||||
|
throw new Exception("API key is already revoked");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke the key
|
||||||
|
$success = $apiKeyModel->revokeKey($keyId);
|
||||||
|
|
||||||
|
if (!$success) {
|
||||||
|
throw new Exception("Failed to revoke API key");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the action
|
||||||
|
$auditLog = new AuditLogModel($conn);
|
||||||
|
$auditLog->log(
|
||||||
|
$_SESSION['user']['user_id'],
|
||||||
|
'revoke',
|
||||||
|
'api_key',
|
||||||
|
$keyId,
|
||||||
|
['key_name' => $keyInfo['key_name'], 'key_prefix' => $keyInfo['key_prefix']]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear output buffer
|
||||||
|
ob_end_clean();
|
||||||
|
|
||||||
|
// Return success
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'API key revoked successfully'
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
ob_end_clean();
|
||||||
|
error_log("Revoke API key error: " . $e->getMessage());
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
http_response_code(isset($conn) ? 400 : 500);
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'An internal error occurred'
|
||||||
|
]);
|
||||||
|
}
|
||||||
+22
-70
@@ -1,49 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saved Filters API Endpoint
|
* Saved Filters API Endpoint
|
||||||
* Handles GET (fetch filters), POST (create filter), PUT (update filter), DELETE (delete filter)
|
* Handles GET (fetch filters), POST (create filter), PUT (update filter), DELETE (delete filter)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/config/config.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
require_once dirname(__DIR__) . '/models/SavedFiltersModel.php';
|
require_once dirname(__DIR__) . '/models/SavedFiltersModel.php';
|
||||||
|
|
||||||
session_start();
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
|
|
||||||
// Check authentication
|
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
|
||||||
http_response_code(401);
|
|
||||||
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// CSRF Protection
|
|
||||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
|
||||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
|
||||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
|
||||||
http_response_code(403);
|
|
||||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$userId = $_SESSION['user']['user_id'];
|
|
||||||
|
|
||||||
// Create database connection
|
|
||||||
$conn = new mysqli(
|
|
||||||
$GLOBALS['config']['DB_HOST'],
|
|
||||||
$GLOBALS['config']['DB_USER'],
|
|
||||||
$GLOBALS['config']['DB_PASS'],
|
|
||||||
$GLOBALS['config']['DB_NAME']
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($conn->connect_error) {
|
|
||||||
http_response_code(500);
|
|
||||||
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$filtersModel = new SavedFiltersModel($conn);
|
$filtersModel = new SavedFiltersModel($conn);
|
||||||
|
|
||||||
// GET - Fetch all saved filters or a specific filter
|
// GET - Fetch all saved filters or a specific filter
|
||||||
@@ -54,25 +18,24 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
$filter = $filtersModel->getFilter($filterId, $userId);
|
$filter = $filtersModel->getFilter($filterId, $userId);
|
||||||
|
|
||||||
if ($filter) {
|
if ($filter) {
|
||||||
echo json_encode(['success' => true, 'filter' => $filter]);
|
apiRespond(['success' => true, 'filter' => $filter]);
|
||||||
} else {
|
} else {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo json_encode(['success' => false, 'error' => 'Filter not found']);
|
apiRespond(['success' => false, 'error' => 'Filter not found']);
|
||||||
}
|
}
|
||||||
} else if (isset($_GET['default'])) {
|
} elseif (isset($_GET['default'])) {
|
||||||
// Get default filter
|
// Get default filter
|
||||||
$filter = $filtersModel->getDefaultFilter($userId);
|
$filter = $filtersModel->getDefaultFilter($userId);
|
||||||
echo json_encode(['success' => true, 'filter' => $filter]);
|
apiRespond(['success' => true, 'filter' => $filter]);
|
||||||
} else {
|
} else {
|
||||||
// Get all filters
|
// Get all filters
|
||||||
$filters = $filtersModel->getUserFilters($userId);
|
$filters = $filtersModel->getUserFilters($userId);
|
||||||
echo json_encode(['success' => true, 'filters' => $filters]);
|
apiRespond(['success' => true, 'filters' => $filters]);
|
||||||
}
|
}
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'Failed to fetch filters']);
|
apiRespond(['success' => false, 'error' => 'Failed to fetch filters']);
|
||||||
}
|
}
|
||||||
$conn->close();
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,8 +45,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
|
|
||||||
if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) {
|
if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
|
apiRespond(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
|
||||||
$conn->close();
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,19 +56,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
// Validate filter name
|
// Validate filter name
|
||||||
if (empty($filterName) || strlen($filterName) > 100) {
|
if (empty($filterName) || strlen($filterName) > 100) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Invalid filter name']);
|
apiRespond(['success' => false, 'error' => 'Invalid filter name']);
|
||||||
$conn->close();
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$result = $filtersModel->saveFilter($userId, $filterName, $filterCriteria, $isDefault);
|
$result = $filtersModel->saveFilter($userId, $filterName, $filterCriteria, $isDefault);
|
||||||
echo json_encode($result);
|
apiRespond($result);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'Failed to save filter']);
|
apiRespond(['success' => false, 'error' => 'Failed to save filter']);
|
||||||
}
|
}
|
||||||
$conn->close();
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,8 +76,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
|
|||||||
|
|
||||||
if (!isset($data['filter_id'])) {
|
if (!isset($data['filter_id'])) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Missing filter_id']);
|
apiRespond(['success' => false, 'error' => 'Missing filter_id']);
|
||||||
$conn->close();
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,20 +86,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
|
|||||||
if (isset($data['set_default']) && $data['set_default'] === true) {
|
if (isset($data['set_default']) && $data['set_default'] === true) {
|
||||||
try {
|
try {
|
||||||
$result = $filtersModel->setDefaultFilter($filterId, $userId);
|
$result = $filtersModel->setDefaultFilter($filterId, $userId);
|
||||||
echo json_encode($result);
|
apiRespond($result);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'Failed to set default filter']);
|
apiRespond(['success' => false, 'error' => 'Failed to set default filter']);
|
||||||
}
|
}
|
||||||
$conn->close();
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle full filter update
|
// Handle full filter update
|
||||||
if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) {
|
if (!isset($data['filter_name']) || !isset($data['filter_criteria'])) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
|
apiRespond(['success' => false, 'error' => 'Missing filter_name or filter_criteria']);
|
||||||
$conn->close();
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,12 +107,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$result = $filtersModel->updateFilter($filterId, $userId, $filterName, $filterCriteria, $isDefault);
|
$result = $filtersModel->updateFilter($filterId, $userId, $filterName, $filterCriteria, $isDefault);
|
||||||
echo json_encode($result);
|
apiRespond($result);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'Failed to update filter']);
|
apiRespond(['success' => false, 'error' => 'Failed to update filter']);
|
||||||
}
|
}
|
||||||
$conn->close();
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,8 +121,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
|||||||
|
|
||||||
if (!isset($data['filter_id'])) {
|
if (!isset($data['filter_id'])) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Missing filter_id']);
|
apiRespond(['success' => false, 'error' => 'Missing filter_id']);
|
||||||
$conn->close();
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,17 +129,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$result = $filtersModel->deleteFilter($filterId, $userId);
|
$result = $filtersModel->deleteFilter($filterId, $userId);
|
||||||
echo json_encode($result);
|
apiRespond($result);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'Failed to delete filter']);
|
apiRespond(['success' => false, 'error' => 'Failed to delete filter']);
|
||||||
}
|
}
|
||||||
$conn->close();
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method not allowed
|
// Method not allowed
|
||||||
http_response_code(405);
|
http_response_code(405);
|
||||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
apiRespond(['success' => false, 'error' => 'Method not allowed']);
|
||||||
$conn->close();
|
|
||||||
?>
|
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ticket Dependencies API
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Immediately set JSON header and start output buffering
|
||||||
|
ob_start();
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// Register shutdown function to catch fatal errors
|
||||||
|
register_shutdown_function(function () {
|
||||||
|
$error = error_get_last();
|
||||||
|
if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
|
||||||
|
// Log detailed error server-side
|
||||||
|
error_log('Fatal error in ticket_dependencies.php: ' . $error['message'] . ' in ' . $error['file'] . ':' . $error['line']);
|
||||||
|
ob_end_clean();
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'A server error occurred'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
// Custom error handler
|
||||||
|
set_error_handler(function ($errno, $errstr, $errfile, $errline) {
|
||||||
|
// Log detailed error server-side
|
||||||
|
error_log("PHP Error in ticket_dependencies.php: $errstr in $errfile:$errline");
|
||||||
|
ob_end_clean();
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'A server error occurred'
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom exception handler
|
||||||
|
set_exception_handler(function ($e) {
|
||||||
|
// Log detailed error server-side
|
||||||
|
error_log('Exception in ticket_dependencies.php: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||||||
|
ob_end_clean();
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'A server error occurred'
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply rate limiting (also starts session)
|
||||||
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
|
// Ensure session is started
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/DependencyModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
ResponseHelper::unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = $_SESSION['user']['user_id'];
|
||||||
|
$currentUser = $_SESSION['user'];
|
||||||
|
|
||||||
|
// CSRF Protection for POST/DELETE
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
||||||
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
|
ResponseHelper::forbidden('Invalid CSRF token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use centralized database connection
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
|
||||||
|
// Check if ticket_dependencies table exists
|
||||||
|
$tableCheck = $conn->query("SHOW TABLES LIKE 'ticket_dependencies'");
|
||||||
|
if ($tableCheck->num_rows === 0) {
|
||||||
|
ResponseHelper::serverError('Ticket dependencies feature not available. The ticket_dependencies table does not exist. Please run the migration.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$dependencyModel = new DependencyModel($conn);
|
||||||
|
$auditLog = new AuditLogModel($conn);
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log('Failed to initialize models in ticket_dependencies.php: ' . $e->getMessage());
|
||||||
|
ResponseHelper::serverError('Failed to initialize required components');
|
||||||
|
}
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch ($method) {
|
||||||
|
case 'GET':
|
||||||
|
// Get dependencies for a ticket
|
||||||
|
$ticketId = $_GET['ticket_id'] ?? null;
|
||||||
|
|
||||||
|
if (!$ticketId) {
|
||||||
|
ResponseHelper::error('Ticket ID required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user can access this ticket
|
||||||
|
$ticket = $ticketModel->getTicketById((int)$ticketId);
|
||||||
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
|
ResponseHelper::notFound('Ticket not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$dependencies = $dependencyModel->getDependencies($ticketId);
|
||||||
|
$dependents = $dependencyModel->getDependentTickets($ticketId);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log('Query error in ticket_dependencies.php GET: ' . $e->getMessage());
|
||||||
|
ResponseHelper::serverError('Failed to retrieve dependencies');
|
||||||
|
}
|
||||||
|
|
||||||
|
ResponseHelper::success([
|
||||||
|
'dependencies' => $dependencies,
|
||||||
|
'dependents' => $dependents
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'POST':
|
||||||
|
// Add a new dependency
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!is_array($data)) {
|
||||||
|
ResponseHelper::error('Invalid JSON');
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketId = $data['ticket_id'] ?? null;
|
||||||
|
$dependsOnId = $data['depends_on_id'] ?? null;
|
||||||
|
$type = $data['dependency_type'] ?? 'blocks';
|
||||||
|
|
||||||
|
if (!$ticketId || !$dependsOnId) {
|
||||||
|
ResponseHelper::error('Both ticket_id and depends_on_id are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user can access both tickets before creating dependency
|
||||||
|
$srcTicket = $ticketModel->getTicketById((int)$ticketId);
|
||||||
|
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
|
||||||
|
ResponseHelper::notFound('Ticket not found');
|
||||||
|
}
|
||||||
|
$tgtTicket = $ticketModel->getTicketById((int)$dependsOnId);
|
||||||
|
if (!$tgtTicket || !$ticketModel->canUserAccessTicket($tgtTicket, $currentUser)) {
|
||||||
|
ResponseHelper::notFound('Target ticket not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $dependencyModel->addDependency($ticketId, $dependsOnId, $type, $userId);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
// Log to audit
|
||||||
|
$auditLog->log($userId, 'create', 'dependency', (string)$result['dependency_id'], [
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'depends_on_id' => $dependsOnId,
|
||||||
|
'type' => $type
|
||||||
|
]);
|
||||||
|
|
||||||
|
ResponseHelper::created($result);
|
||||||
|
} else {
|
||||||
|
ResponseHelper::error($result['error']);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'DELETE':
|
||||||
|
// Remove a dependency
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!is_array($data)) {
|
||||||
|
ResponseHelper::error('Invalid JSON');
|
||||||
|
}
|
||||||
|
|
||||||
|
$dependencyId = $data['dependency_id'] ?? null;
|
||||||
|
|
||||||
|
// Alternative: delete by ticket IDs
|
||||||
|
if (!$dependencyId && isset($data['ticket_id']) && isset($data['depends_on_id'])) {
|
||||||
|
$ticketId = $data['ticket_id'];
|
||||||
|
$dependsOnId = $data['depends_on_id'];
|
||||||
|
$type = $data['dependency_type'] ?? 'blocks';
|
||||||
|
|
||||||
|
// Validate dependency type
|
||||||
|
$validTypes = ['blocks', 'blocked_by', 'relates_to', 'duplicates'];
|
||||||
|
if (!in_array($type, $validTypes, true)) {
|
||||||
|
ResponseHelper::error('Invalid dependency type');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user can access the source ticket
|
||||||
|
$srcTicket = $ticketModel->getTicketById((int)$ticketId);
|
||||||
|
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
|
||||||
|
ResponseHelper::notFound('Ticket not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $dependencyModel->removeDependencyByTickets($ticketId, $dependsOnId, $type);
|
||||||
|
|
||||||
|
if ($result) {
|
||||||
|
$auditLog->log($userId, 'delete', 'dependency', null, [
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'depends_on_id' => $dependsOnId,
|
||||||
|
'type' => $type
|
||||||
|
]);
|
||||||
|
ResponseHelper::success([], 'Dependency removed');
|
||||||
|
} else {
|
||||||
|
ResponseHelper::error('Failed to remove dependency');
|
||||||
|
}
|
||||||
|
} elseif ($dependencyId) {
|
||||||
|
// Look up dependency to verify ticket access before deletion
|
||||||
|
$depLookupSql = "SELECT ticket_id FROM ticket_dependencies WHERE dependency_id = ?";
|
||||||
|
$depLookupStmt = $conn->prepare($depLookupSql);
|
||||||
|
$depLookupStmt->bind_param("i", $dependencyId);
|
||||||
|
$depLookupStmt->execute();
|
||||||
|
$depRow = $depLookupStmt->get_result()->fetch_assoc();
|
||||||
|
$depLookupStmt->close();
|
||||||
|
|
||||||
|
if (!$depRow) {
|
||||||
|
ResponseHelper::notFound('Dependency not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$depTicket = $ticketModel->getTicketById((int)$depRow['ticket_id']);
|
||||||
|
if (!$depTicket || !$ticketModel->canUserAccessTicket($depTicket, $currentUser)) {
|
||||||
|
ResponseHelper::forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $dependencyModel->removeDependency($dependencyId);
|
||||||
|
|
||||||
|
if ($result) {
|
||||||
|
$auditLog->log($userId, 'delete', 'dependency', (string)$dependencyId);
|
||||||
|
ResponseHelper::success([], 'Dependency removed');
|
||||||
|
} else {
|
||||||
|
ResponseHelper::error('Failed to remove dependency');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ResponseHelper::error('Dependency ID or ticket IDs required');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
ResponseHelper::error('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// Log detailed error server-side
|
||||||
|
error_log('Ticket dependencies API error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||||||
|
ResponseHelper::serverError('An error occurred while processing the dependency request');
|
||||||
|
};
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API endpoint for updating a comment
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Disable error display in the output
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
// Apply rate limiting
|
||||||
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
|
// Start output buffering
|
||||||
|
ob_start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
|
||||||
|
// Check authentication via session
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
throw new Exception("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF Protection
|
||||||
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'PUT') {
|
||||||
|
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentUser = $_SESSION['user'];
|
||||||
|
$userId = $currentUser['user_id'];
|
||||||
|
$isAdmin = $currentUser['is_admin'] ?? false;
|
||||||
|
|
||||||
|
// Use centralized database connection
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
|
||||||
|
// Get POST/PUT data
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
|
if (!$data || !isset($data['comment_id']) || !isset($data['comment_text'])) {
|
||||||
|
throw new Exception("Missing required fields: comment_id, comment_text");
|
||||||
|
}
|
||||||
|
|
||||||
|
$commentId = (int)$data['comment_id'];
|
||||||
|
$commentText = trim($data['comment_text']);
|
||||||
|
$markdownEnabled = isset($data['markdown_enabled']) && $data['markdown_enabled'];
|
||||||
|
|
||||||
|
if (empty($commentText)) {
|
||||||
|
throw new Exception("Comment text cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize models
|
||||||
|
$commentModel = new CommentModel($conn);
|
||||||
|
$auditLog = new AuditLogModel($conn);
|
||||||
|
|
||||||
|
// Verify user can access the parent ticket
|
||||||
|
$comment = $commentModel->getCommentById($commentId);
|
||||||
|
if ($comment) {
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
$ticket = $ticketModel->getTicketById($comment['ticket_id']);
|
||||||
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
|
ob_end_clean();
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Access denied']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update comment
|
||||||
|
$result = $commentModel->updateComment($commentId, $commentText, $markdownEnabled, $userId, $isAdmin);
|
||||||
|
|
||||||
|
// Log the update if successful
|
||||||
|
if ($result['success']) {
|
||||||
|
$auditLog->log(
|
||||||
|
$userId,
|
||||||
|
'update',
|
||||||
|
'comment',
|
||||||
|
(string)$commentId,
|
||||||
|
['comment_text_preview' => substr($commentText, 0, 100)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discard any unexpected output
|
||||||
|
ob_end_clean();
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode($result);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
ob_end_clean();
|
||||||
|
error_log("Update comment API error: " . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'An internal error occurred'
|
||||||
|
]);
|
||||||
|
}
|
||||||
+149
-200
@@ -1,61 +1,38 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// Enable error reporting for debugging
|
// Enable error reporting for debugging
|
||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
ini_set('display_errors', 0); // Don't display errors in the response
|
ini_set('display_errors', 0); // Don't display errors in the response
|
||||||
|
|
||||||
// Define a debug log function
|
// Apply rate limiting
|
||||||
function debug_log($message) {
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
file_put_contents('/tmp/api_debug.log', date('Y-m-d H:i:s') . " - $message\n", FILE_APPEND);
|
RateLimitMiddleware::apply('api');
|
||||||
}
|
|
||||||
|
|
||||||
// Start output buffering to capture any errors
|
// Start output buffering to capture any errors
|
||||||
ob_start();
|
ob_start();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
debug_log("Script started");
|
|
||||||
|
|
||||||
// Load config
|
// Load config
|
||||||
$configPath = dirname(__DIR__) . '/config/config.php';
|
$configPath = dirname(__DIR__) . '/config/config.php';
|
||||||
debug_log("Loading config from: $configPath");
|
|
||||||
require_once $configPath;
|
require_once $configPath;
|
||||||
debug_log("Config loaded successfully");
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
|
||||||
// Load environment variables (for Discord webhook)
|
|
||||||
$envPath = dirname(__DIR__) . '/.env';
|
|
||||||
$envVars = [];
|
|
||||||
if (file_exists($envPath)) {
|
|
||||||
$lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
|
||||||
foreach ($lines as $line) {
|
|
||||||
if (strpos($line, '=') !== false && strpos($line, '#') !== 0) {
|
|
||||||
list($key, $value) = explode('=', $line, 2);
|
|
||||||
$key = trim($key);
|
|
||||||
$value = trim($value);
|
|
||||||
// Remove surrounding quotes if present
|
|
||||||
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
|
||||||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
|
|
||||||
$value = substr($value, 1, -1);
|
|
||||||
}
|
|
||||||
$envVars[$key] = $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
debug_log("Environment variables loaded");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load models directly with absolute paths
|
// Load models directly with absolute paths
|
||||||
$ticketModelPath = dirname(__DIR__) . '/models/TicketModel.php';
|
$ticketModelPath = dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
$commentModelPath = dirname(__DIR__) . '/models/CommentModel.php';
|
$commentModelPath = dirname(__DIR__) . '/models/CommentModel.php';
|
||||||
$auditLogModelPath = dirname(__DIR__) . '/models/AuditLogModel.php';
|
$auditLogModelPath = dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
$workflowModelPath = dirname(__DIR__) . '/models/WorkflowModel.php';
|
$workflowModelPath = dirname(__DIR__) . '/models/WorkflowModel.php';
|
||||||
|
|
||||||
debug_log("Loading models from: $ticketModelPath and $commentModelPath");
|
|
||||||
require_once $ticketModelPath;
|
require_once $ticketModelPath;
|
||||||
require_once $commentModelPath;
|
require_once $commentModelPath;
|
||||||
require_once $auditLogModelPath;
|
require_once $auditLogModelPath;
|
||||||
require_once $workflowModelPath;
|
require_once $workflowModelPath;
|
||||||
debug_log("Models loaded successfully");
|
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
||||||
|
|
||||||
// Check authentication via session
|
// Check authentication via session
|
||||||
session_start();
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
throw new Exception("Authentication required");
|
throw new Exception("Authentication required");
|
||||||
}
|
}
|
||||||
@@ -75,31 +52,33 @@ try {
|
|||||||
$currentUser = $_SESSION['user'];
|
$currentUser = $_SESSION['user'];
|
||||||
$userId = $currentUser['user_id'];
|
$userId = $currentUser['user_id'];
|
||||||
$isAdmin = $currentUser['is_admin'] ?? false;
|
$isAdmin = $currentUser['is_admin'] ?? false;
|
||||||
debug_log("User authenticated: " . $currentUser['username'] . " (admin: " . ($isAdmin ? 'yes' : 'no') . ")");
|
|
||||||
|
|
||||||
// Updated controller class that handles partial updates
|
// Updated controller class that handles partial updates
|
||||||
class ApiTicketController {
|
class ApiTicketController
|
||||||
|
{
|
||||||
|
private $conn;
|
||||||
private $ticketModel;
|
private $ticketModel;
|
||||||
private $commentModel;
|
private $commentModel;
|
||||||
private $auditLog;
|
private $auditLog;
|
||||||
private $workflowModel;
|
private $workflowModel;
|
||||||
private $envVars;
|
|
||||||
private $userId;
|
private $userId;
|
||||||
private $isAdmin;
|
private $isAdmin;
|
||||||
|
private $currentUser;
|
||||||
|
|
||||||
public function __construct($conn, $envVars = [], $userId = null, $isAdmin = false) {
|
public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = [])
|
||||||
|
{
|
||||||
|
$this->conn = $conn;
|
||||||
$this->ticketModel = new TicketModel($conn);
|
$this->ticketModel = new TicketModel($conn);
|
||||||
$this->commentModel = new CommentModel($conn);
|
$this->commentModel = new CommentModel($conn);
|
||||||
$this->auditLog = new AuditLogModel($conn);
|
$this->auditLog = new AuditLogModel($conn);
|
||||||
$this->workflowModel = new WorkflowModel($conn);
|
$this->workflowModel = new WorkflowModel($conn);
|
||||||
$this->envVars = $envVars;
|
|
||||||
$this->userId = $userId;
|
$this->userId = $userId;
|
||||||
$this->isAdmin = $isAdmin;
|
$this->isAdmin = $isAdmin;
|
||||||
|
$this->currentUser = $currentUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update($id, $data) {
|
public function update($id, $data)
|
||||||
debug_log("ApiTicketController->update called with ID: $id and data: " . json_encode($data));
|
{
|
||||||
|
|
||||||
// First, get the current ticket data to fill in missing fields
|
// First, get the current ticket data to fill in missing fields
|
||||||
$currentTicket = $this->ticketModel->getTicketById($id);
|
$currentTicket = $this->ticketModel->getTicketById($id);
|
||||||
if (!$currentTicket) {
|
if (!$currentTicket) {
|
||||||
@@ -108,9 +87,19 @@ try {
|
|||||||
'error' => 'Ticket not found'
|
'error' => 'Ticket not found'
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
debug_log("Current ticket data: " . json_encode($currentTicket));
|
// Visibility check: return 404 for tickets the user cannot access
|
||||||
|
if (!$this->ticketModel->canUserAccessTicket($currentTicket, $this->currentUser)) {
|
||||||
|
return [
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Ticket not found',
|
||||||
|
'http_status' => 404
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any authenticated team member can update tickets.
|
||||||
|
// Admin-only operations (delete, bulk actions) are enforced separately.
|
||||||
|
|
||||||
// Merge current data with updates, keeping existing values for missing fields
|
// Merge current data with updates, keeping existing values for missing fields
|
||||||
$updateData = [
|
$updateData = [
|
||||||
'ticket_id' => $id,
|
'ticket_id' => $id,
|
||||||
@@ -121,9 +110,7 @@ try {
|
|||||||
'status' => $data['status'] ?? $currentTicket['status'],
|
'status' => $data['status'] ?? $currentTicket['status'],
|
||||||
'priority' => isset($data['priority']) ? (int)$data['priority'] : (int)$currentTicket['priority']
|
'priority' => isset($data['priority']) ? (int)$data['priority'] : (int)$currentTicket['priority']
|
||||||
];
|
];
|
||||||
|
|
||||||
debug_log("Merged update data: " . json_encode($updateData));
|
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (empty($updateData['title'])) {
|
if (empty($updateData['title'])) {
|
||||||
return [
|
return [
|
||||||
@@ -131,7 +118,7 @@ try {
|
|||||||
'error' => 'Title cannot be empty'
|
'error' => 'Title cannot be empty'
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate priority range
|
// Validate priority range
|
||||||
if ($updateData['priority'] < 1 || $updateData['priority'] > 5) {
|
if ($updateData['priority'] < 1 || $updateData['priority'] > 5) {
|
||||||
return [
|
return [
|
||||||
@@ -139,7 +126,7 @@ try {
|
|||||||
'error' => 'Priority must be between 1 and 5'
|
'error' => 'Priority must be between 1 and 5'
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate status transition using workflow model
|
// Validate status transition using workflow model
|
||||||
if ($currentTicket['status'] !== $updateData['status']) {
|
if ($currentTicket['status'] !== $updateData['status']) {
|
||||||
$allowed = $this->workflowModel->isTransitionAllowed(
|
$allowed = $this->workflowModel->isTransitionAllowed(
|
||||||
@@ -155,197 +142,159 @@ try {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debug_log("Validation passed, calling ticketModel->updateTicket");
|
|
||||||
|
|
||||||
// Update ticket with user tracking
|
// Update ticket with user tracking and optional optimistic locking
|
||||||
$result = $this->ticketModel->updateTicket($updateData, $this->userId);
|
$expectedUpdatedAt = $data['expected_updated_at'] ?? null;
|
||||||
|
$result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt);
|
||||||
|
|
||||||
debug_log("TicketModel->updateTicket returned: " . ($result ? 'true' : 'false'));
|
// Handle conflict case
|
||||||
|
if (!$result['success']) {
|
||||||
|
$response = [
|
||||||
|
'success' => false,
|
||||||
|
'error' => $result['error'] ?? 'Failed to update ticket in database'
|
||||||
|
];
|
||||||
|
if (!empty($result['conflict'])) {
|
||||||
|
$response['conflict'] = true;
|
||||||
|
$response['current_updated_at'] = $result['current_updated_at'] ?? null;
|
||||||
|
}
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
if ($result) {
|
// Handle visibility update if provided
|
||||||
// Log ticket update to audit log
|
if (isset($data['visibility'])) {
|
||||||
if ($this->userId) {
|
$visibilityGroups = $data['visibility_groups'] ?? null;
|
||||||
$this->auditLog->logTicketUpdate($this->userId, $id, $data);
|
// Convert array to comma-separated string if needed
|
||||||
|
if (is_array($visibilityGroups)) {
|
||||||
|
$visibilityGroups = implode(',', array_map('trim', $visibilityGroups));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discord webhook disabled for updates - only send for new tickets
|
// Validate internal visibility requires groups
|
||||||
// $this->sendDiscordWebhook($id, $currentTicket, $updateData, $data);
|
if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) {
|
||||||
|
return [
|
||||||
return [
|
'success' => false,
|
||||||
'success' => true,
|
'error' => 'Internal visibility requires at least one group to be specified'
|
||||||
'status' => $updateData['status'],
|
|
||||||
'priority' => $updateData['priority'],
|
|
||||||
'message' => 'Ticket updated successfully'
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
return [
|
|
||||||
'success' => false,
|
|
||||||
'error' => 'Failed to update ticket in database'
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function sendDiscordWebhook($ticketId, $oldData, $newData, $changedFields) {
|
|
||||||
if (!isset($this->envVars['DISCORD_WEBHOOK_URL']) || empty($this->envVars['DISCORD_WEBHOOK_URL'])) {
|
|
||||||
debug_log("Discord webhook URL not configured, skipping webhook");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$webhookUrl = $this->envVars['DISCORD_WEBHOOK_URL'];
|
|
||||||
debug_log("Sending Discord webhook to: $webhookUrl");
|
|
||||||
|
|
||||||
// Determine what fields actually changed
|
|
||||||
$changes = [];
|
|
||||||
foreach ($changedFields as $field => $newValue) {
|
|
||||||
if ($field === 'ticket_id') continue; // Skip ticket_id
|
|
||||||
|
|
||||||
$oldValue = $oldData[$field] ?? 'N/A';
|
|
||||||
if ($oldValue != $newValue) {
|
|
||||||
$changes[] = [
|
|
||||||
'name' => ucfirst($field),
|
|
||||||
'value' => "$oldValue → $newValue",
|
|
||||||
'inline' => true
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
|
||||||
|
if ($visResult && $this->userId) {
|
||||||
|
$this->auditLog->log(
|
||||||
|
$this->userId,
|
||||||
|
'update',
|
||||||
|
'ticket',
|
||||||
|
(string)$id,
|
||||||
|
[
|
||||||
|
'field' => 'visibility',
|
||||||
|
'from' => $currentTicket['visibility'] ?? 'public',
|
||||||
|
'to' => $data['visibility'],
|
||||||
|
'groups' => $visibilityGroups
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (empty($changes)) {
|
// Log ticket update to audit log — only the changed fields (delta)
|
||||||
debug_log("No actual changes detected, skipping webhook");
|
if ($this->userId) {
|
||||||
return;
|
$trackFields = ['title', 'priority', 'status', 'description', 'category', 'type'];
|
||||||
|
$delta = [];
|
||||||
|
foreach ($trackFields as $field) {
|
||||||
|
$oldVal = (string)($currentTicket[$field] ?? '');
|
||||||
|
$newVal = (string)($updateData[$field] ?? '');
|
||||||
|
if ($oldVal !== $newVal) {
|
||||||
|
$delta[$field] = ['from' => $oldVal, 'to' => $newVal];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($delta)) {
|
||||||
|
$this->auditLog->logTicketUpdate($this->userId, $id, $delta);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create ticket URL
|
// Notify on status change (global notify list + watchers)
|
||||||
$ticketUrl = "http://t.lotusguild.org/ticket/$ticketId";
|
if ($currentTicket['status'] !== $updateData['status']) {
|
||||||
|
$changedBy = $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? null;
|
||||||
// Determine embed color based on priority
|
NotificationHelper::sendStatusChangeNotification(
|
||||||
$colors = [
|
$id,
|
||||||
1 => 0xff4d4d, // Red
|
$currentTicket['status'],
|
||||||
2 => 0xffa726, // Orange
|
$updateData['status'],
|
||||||
3 => 0x42a5f5, // Blue
|
$updateData['title'],
|
||||||
4 => 0x66bb6a, // Green
|
$changedBy
|
||||||
5 => 0x9e9e9e // Gray
|
);
|
||||||
];
|
NotificationHelper::notifyWatchers(
|
||||||
$color = $colors[$newData['priority']] ?? 0x3498db;
|
$this->conn,
|
||||||
|
$id,
|
||||||
$embed = [
|
$updateData['title'],
|
||||||
'title' => '🔄 Ticket Updated',
|
'status_changed',
|
||||||
'description' => "**#{$ticketId}** - " . $newData['title'],
|
['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy],
|
||||||
'color' => $color,
|
(int)$this->userId
|
||||||
'fields' => array_merge($changes, [
|
);
|
||||||
[
|
|
||||||
'name' => '🔗 View Ticket',
|
|
||||||
'value' => "[Click here to view]($ticketUrl)",
|
|
||||||
'inline' => false
|
|
||||||
]
|
|
||||||
]),
|
|
||||||
'footer' => [
|
|
||||||
'text' => 'Tinker Tickets'
|
|
||||||
],
|
|
||||||
'timestamp' => date('c')
|
|
||||||
];
|
|
||||||
|
|
||||||
$payload = [
|
|
||||||
'embeds' => [$embed]
|
|
||||||
];
|
|
||||||
|
|
||||||
debug_log("Discord payload: " . json_encode($payload));
|
|
||||||
|
|
||||||
// Send webhook
|
|
||||||
$ch = curl_init($webhookUrl);
|
|
||||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
|
||||||
curl_setopt($ch, CURLOPT_POST, 1);
|
|
||||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
|
||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
||||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
|
||||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
|
||||||
|
|
||||||
$webhookResult = curl_exec($ch);
|
|
||||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
||||||
$curlError = curl_error($ch);
|
|
||||||
curl_close($ch);
|
|
||||||
|
|
||||||
if ($curlError) {
|
|
||||||
debug_log("Discord webhook cURL error: $curlError");
|
|
||||||
} else {
|
|
||||||
debug_log("Discord webhook sent. HTTP Code: $httpCode, Response: $webhookResult");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'status' => $updateData['status'],
|
||||||
|
'priority' => $updateData['priority'],
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
'message' => 'Ticket updated successfully'
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debug_log("Controller defined successfully");
|
// Use centralized database connection
|
||||||
|
$conn = Database::getConnection();
|
||||||
// Create database connection
|
|
||||||
debug_log("Creating database connection");
|
|
||||||
$conn = new mysqli(
|
|
||||||
$GLOBALS['config']['DB_HOST'],
|
|
||||||
$GLOBALS['config']['DB_USER'],
|
|
||||||
$GLOBALS['config']['DB_PASS'],
|
|
||||||
$GLOBALS['config']['DB_NAME']
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($conn->connect_error) {
|
|
||||||
throw new Exception("Database connection failed: " . $conn->connect_error);
|
|
||||||
}
|
|
||||||
debug_log("Database connection successful");
|
|
||||||
|
|
||||||
// Check request method
|
// Check request method
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
throw new Exception("Method not allowed. Expected POST, got " . $_SERVER['REQUEST_METHOD']);
|
throw new Exception("Method not allowed. Expected POST, got " . $_SERVER['REQUEST_METHOD']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get POST data
|
// Get POST data
|
||||||
$input = file_get_contents('php://input');
|
$input = file_get_contents('php://input');
|
||||||
$data = json_decode($input, true);
|
$data = json_decode($input, true);
|
||||||
debug_log("Received raw input: " . $input);
|
|
||||||
debug_log("Decoded data: " . json_encode($data));
|
|
||||||
|
|
||||||
if (!$data) {
|
if (!$data) {
|
||||||
throw new Exception("Invalid JSON data received: " . $input);
|
throw new Exception("Invalid JSON data received: " . $input);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isset($data['ticket_id'])) {
|
if (!isset($data['ticket_id'])) {
|
||||||
throw new Exception("Missing ticket_id parameter");
|
throw new Exception("Missing ticket_id parameter");
|
||||||
}
|
}
|
||||||
|
|
||||||
$ticketId = (int)$data['ticket_id'];
|
$ticketId = trim((string)$data['ticket_id']);
|
||||||
debug_log("Processing ticket ID: $ticketId");
|
|
||||||
|
|
||||||
// Initialize controller
|
// Initialize controller
|
||||||
debug_log("Initializing controller");
|
$controller = new ApiTicketController($conn, $userId, $isAdmin, $currentUser);
|
||||||
$controller = new ApiTicketController($conn, $envVars, $userId, $isAdmin);
|
|
||||||
debug_log("Controller initialized");
|
|
||||||
|
|
||||||
// Update ticket
|
// Update ticket
|
||||||
debug_log("Calling controller update method");
|
|
||||||
$result = $controller->update($ticketId, $data);
|
$result = $controller->update($ticketId, $data);
|
||||||
debug_log("Update completed with result: " . json_encode($result));
|
|
||||||
|
|
||||||
// Close database connection
|
|
||||||
$conn->close();
|
|
||||||
|
|
||||||
// Discard any output that might have been generated
|
// Discard any output that might have been generated
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
|
|
||||||
|
// Invalidate stats cache on successful ticket update
|
||||||
|
if (!empty($result['success'])) {
|
||||||
|
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
||||||
|
(new StatsModel($conn))->invalidateCache();
|
||||||
|
}
|
||||||
|
|
||||||
// Return response
|
// Return response
|
||||||
|
if (!empty($result['http_status'])) {
|
||||||
|
http_response_code($result['http_status']);
|
||||||
|
unset($result['http_status']);
|
||||||
|
}
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
debug_log("Response sent successfully");
|
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
debug_log("Error: " . $e->getMessage());
|
|
||||||
debug_log("Stack trace: " . $e->getTraceAsString());
|
|
||||||
|
|
||||||
// Discard any output that might have been generated
|
// Discard any output that might have been generated
|
||||||
ob_end_clean();
|
ob_end_clean();
|
||||||
|
|
||||||
|
// Log error details but don't expose to client
|
||||||
|
error_log("Update ticket API error: " . $e->getMessage());
|
||||||
|
|
||||||
// Return error response
|
// Return error response
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => $e->getMessage()
|
'error' => 'An internal error occurred'
|
||||||
]);
|
]);
|
||||||
debug_log("Error response sent");
|
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload Attachment API
|
||||||
|
*
|
||||||
|
* Handles file uploads for ticket attachments
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Capture errors for debugging
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
// Apply rate limiting (also starts session)
|
||||||
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
|
// Ensure session is started
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AttachmentModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// Check authentication
|
||||||
|
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||||
|
ResponseHelper::unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle GET requests to list attachments
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||||
|
$ticketId = $_GET['ticket_id'] ?? '';
|
||||||
|
|
||||||
|
if (empty($ticketId)) {
|
||||||
|
ResponseHelper::error('Ticket ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate ticket ID format (positive integer)
|
||||||
|
if (!preg_match('/^\d+$/', $ticketId)) {
|
||||||
|
ResponseHelper::error('Invalid ticket ID format');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
$ticket = $ticketModel->getTicketById((int)$ticketId);
|
||||||
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
|
||||||
|
ResponseHelper::notFound('Ticket not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachmentModel = new AttachmentModel($conn);
|
||||||
|
$attachments = $attachmentModel->getAttachments($ticketId);
|
||||||
|
|
||||||
|
// Add formatted file size and icon to each attachment
|
||||||
|
foreach ($attachments as &$att) {
|
||||||
|
$att['file_size_formatted'] = AttachmentModel::formatFileSize($att['file_size']);
|
||||||
|
$att['icon'] = AttachmentModel::getFileIcon($att['mime_type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
ResponseHelper::success(['attachments' => $attachments]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
ResponseHelper::serverError('Failed to load attachments');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only accept POST requests for uploads
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
ResponseHelper::error('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify CSRF token
|
||||||
|
$csrfToken = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
||||||
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
|
ResponseHelper::forbidden('Invalid CSRF token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get ticket ID
|
||||||
|
$ticketId = $_POST['ticket_id'] ?? '';
|
||||||
|
if (empty($ticketId)) {
|
||||||
|
ResponseHelper::error('Ticket ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate ticket ID format (positive integer)
|
||||||
|
if (!preg_match('/^\d+$/', $ticketId)) {
|
||||||
|
ResponseHelper::error('Invalid ticket ID format');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user can access the ticket before accepting upload
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
$ticket = $ticketModel->getTicketById((int)$ticketId);
|
||||||
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) {
|
||||||
|
ResponseHelper::notFound('Ticket not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file was uploaded
|
||||||
|
if (!isset($_FILES['file']) || $_FILES['file']['error'] === UPLOAD_ERR_NO_FILE) {
|
||||||
|
ResponseHelper::error('No file uploaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $_FILES['file'];
|
||||||
|
|
||||||
|
// Check for upload errors
|
||||||
|
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
$errorMessages = [
|
||||||
|
UPLOAD_ERR_INI_SIZE => 'File exceeds upload_max_filesize directive',
|
||||||
|
UPLOAD_ERR_FORM_SIZE => 'File exceeds MAX_FILE_SIZE directive',
|
||||||
|
UPLOAD_ERR_PARTIAL => 'File was only partially uploaded',
|
||||||
|
UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder',
|
||||||
|
UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk',
|
||||||
|
UPLOAD_ERR_EXTENSION => 'File upload stopped by extension'
|
||||||
|
];
|
||||||
|
$message = $errorMessages[$file['error']] ?? 'Unknown upload error';
|
||||||
|
ResponseHelper::error($message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file size
|
||||||
|
$maxSize = $GLOBALS['config']['MAX_UPLOAD_SIZE'] ?? 10485760; // 10MB default
|
||||||
|
if ($file['size'] > $maxSize) {
|
||||||
|
ResponseHelper::error('File size exceeds maximum allowed (' . AttachmentModel::formatFileSize($maxSize) . ')');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get MIME type
|
||||||
|
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||||
|
$mimeType = $finfo->file($file['tmp_name']);
|
||||||
|
|
||||||
|
// Validate file type
|
||||||
|
if (!AttachmentModel::isAllowedType($mimeType)) {
|
||||||
|
ResponseHelper::error('File type not allowed: ' . $mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create upload directory if it doesn't exist
|
||||||
|
$uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads';
|
||||||
|
if (!is_dir($uploadDir)) {
|
||||||
|
if (!mkdir($uploadDir, 0755, true)) {
|
||||||
|
ResponseHelper::serverError('Failed to create upload directory');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create ticket subdirectory
|
||||||
|
$ticketDir = $uploadDir . '/' . $ticketId;
|
||||||
|
if (!is_dir($ticketDir)) {
|
||||||
|
if (!mkdir($ticketDir, 0755, true)) {
|
||||||
|
ResponseHelper::serverError('Failed to create ticket upload directory');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive extension from validated MIME type (never from user-supplied filename)
|
||||||
|
// This prevents executable extension attacks (e.g. evil.php disguised as text/plain)
|
||||||
|
$mimeToExt = [
|
||||||
|
'image/jpeg' => 'jpg', 'image/png' => 'png',
|
||||||
|
'image/gif' => 'gif', 'image/webp' => 'webp',
|
||||||
|
'application/pdf' => 'pdf',
|
||||||
|
'text/plain' => 'txt', 'text/csv' => 'csv',
|
||||||
|
'application/msword' => 'doc',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
|
||||||
|
'application/vnd.ms-excel' => 'xls',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
|
||||||
|
'application/zip' => 'zip',
|
||||||
|
'application/x-7z-compressed' => '7z',
|
||||||
|
'application/x-tar' => 'tar',
|
||||||
|
'application/gzip' => 'gz',
|
||||||
|
'application/json' => 'json',
|
||||||
|
'application/xml' => 'xml',
|
||||||
|
];
|
||||||
|
$safeExtension = $mimeToExt[$mimeType] ?? 'bin';
|
||||||
|
$uniqueFilename = uniqid('att_', true) . '.' . $safeExtension;
|
||||||
|
$targetPath = $ticketDir . '/' . $uniqueFilename;
|
||||||
|
|
||||||
|
// Move uploaded file
|
||||||
|
if (!move_uploaded_file($file['tmp_name'], $targetPath)) {
|
||||||
|
ResponseHelper::serverError('Failed to move uploaded file');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize original filename
|
||||||
|
$originalFilename = basename($file['name']);
|
||||||
|
$originalFilename = preg_replace('/[^\w\s\-\.]/', '', $originalFilename);
|
||||||
|
if (empty($originalFilename)) {
|
||||||
|
$originalFilename = 'attachment' . ($safeExtension ? '.' . $safeExtension : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to database
|
||||||
|
try {
|
||||||
|
$attachmentModel = new AttachmentModel($conn);
|
||||||
|
$attachmentId = $attachmentModel->addAttachment(
|
||||||
|
$ticketId,
|
||||||
|
$uniqueFilename,
|
||||||
|
$originalFilename,
|
||||||
|
$file['size'],
|
||||||
|
$mimeType,
|
||||||
|
$_SESSION['user']['user_id']
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$attachmentId) {
|
||||||
|
// Clean up file if database insert fails
|
||||||
|
unlink($targetPath);
|
||||||
|
ResponseHelper::serverError('Failed to save attachment record');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the upload
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
$auditLog = new AuditLogModel($conn);
|
||||||
|
$auditLog->log(
|
||||||
|
$_SESSION['user']['user_id'],
|
||||||
|
'attachment_upload',
|
||||||
|
'ticket_attachments',
|
||||||
|
(string)$attachmentId,
|
||||||
|
[
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'filename' => $originalFilename,
|
||||||
|
'size' => $file['size'],
|
||||||
|
'mime_type' => $mimeType
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
ResponseHelper::created([
|
||||||
|
'attachment_id' => $attachmentId,
|
||||||
|
'filename' => $originalFilename,
|
||||||
|
'file_size' => $file['size'],
|
||||||
|
'file_size_formatted' => AttachmentModel::formatFileSize($file['size']),
|
||||||
|
'mime_type' => $mimeType,
|
||||||
|
'icon' => AttachmentModel::getFileIcon($mimeType),
|
||||||
|
'uploaded_by' => $_SESSION['user']['display_name'] ?? $_SESSION['user']['username'],
|
||||||
|
'uploaded_at' => date('Y-m-d H:i:s')
|
||||||
|
], 'File uploaded successfully');
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// Clean up file on error
|
||||||
|
if (file_exists($targetPath)) {
|
||||||
|
unlink($targetPath);
|
||||||
|
}
|
||||||
|
ResponseHelper::serverError('Failed to process attachment');
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User Avatar API
|
||||||
|
*
|
||||||
|
* Serves profile pictures fetched from lldap via LDAP.
|
||||||
|
* Caches images locally to avoid repeated LDAP queries.
|
||||||
|
*
|
||||||
|
* GET /api/user_avatar.php?user_id=123
|
||||||
|
* Returns the user's JPEG avatar (from cache or LDAP).
|
||||||
|
* Returns 404 if the user has no avatar set in lldap.
|
||||||
|
*/
|
||||||
|
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||||
|
RateLimitMiddleware::apply('api');
|
||||||
|
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
|
||||||
|
// Must be authenticated
|
||||||
|
if (!isset($_SESSION['user']['user_id'])) {
|
||||||
|
http_response_code(401);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
http_response_code(405);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cfg = $GLOBALS['config'];
|
||||||
|
|
||||||
|
// Validate user_id parameter
|
||||||
|
$userId = isset($_GET['user_id']) ? (int)$_GET['user_id'] : 0;
|
||||||
|
if ($userId <= 0) {
|
||||||
|
http_response_code(400);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure LDAP is enabled and extension is loaded
|
||||||
|
if (!$cfg['LDAP_ENABLED'] || !extension_loaded('ldap')) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure avatar cache directory exists
|
||||||
|
$cacheDir = rtrim($cfg['AVATAR_CACHE_DIR'], '/');
|
||||||
|
if (!is_dir($cacheDir)) {
|
||||||
|
mkdir($cacheDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheFile = $cacheDir . '/user_' . $userId . '.jpg';
|
||||||
|
$cacheTtl = (int)($cfg['AVATAR_CACHE_TTL'] ?? 3600);
|
||||||
|
|
||||||
|
// Serve from cache if fresh
|
||||||
|
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTtl) {
|
||||||
|
header('Content-Type: image/jpeg');
|
||||||
|
header('Cache-Control: private, max-age=' . $cacheTtl);
|
||||||
|
header('X-Avatar-Source: cache');
|
||||||
|
readfile($cacheFile);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A sentinel empty file means "no avatar" — don't re-query LDAP until TTL expires
|
||||||
|
$noAvatarSentinel = $cacheDir . '/user_' . $userId . '.none';
|
||||||
|
if (file_exists($noAvatarSentinel) && (time() - filemtime($noAvatarSentinel)) < $cacheTtl) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up username from DB
|
||||||
|
try {
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
$stmt = $conn->prepare("SELECT username FROM users WHERE user_id = ? LIMIT 1");
|
||||||
|
$stmt->bind_param('i', $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
$row = $result->fetch_assoc();
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
if (!$row || empty($row['username'])) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$username = $row['username'];
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log("user_avatar: DB error for user_id=$userId: " . $e->getMessage());
|
||||||
|
http_response_code(500);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query lldap via LDAP
|
||||||
|
$ldapHost = $cfg['LDAP_HOST'];
|
||||||
|
$ldapPort = $cfg['LDAP_PORT'];
|
||||||
|
$bindDn = $cfg['LDAP_BIND_DN'];
|
||||||
|
$bindPw = $cfg['LDAP_BIND_PW'];
|
||||||
|
$userBase = $cfg['LDAP_USER_BASE'];
|
||||||
|
|
||||||
|
// Escape username for LDAP filter (RFC 4515)
|
||||||
|
$safeUsername = ldap_escape($username, '', LDAP_ESCAPE_FILTER);
|
||||||
|
$filter = "(uid=$safeUsername)";
|
||||||
|
|
||||||
|
$avatarData = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ldap = @ldap_connect("ldap://$ldapHost:$ldapPort");
|
||||||
|
if (!$ldap) {
|
||||||
|
throw new RuntimeException("ldap_connect failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3);
|
||||||
|
ldap_set_option($ldap, LDAP_OPT_REFERRALS, 0);
|
||||||
|
ldap_set_option($ldap, LDAP_OPT_NETWORK_TIMEOUT, 3);
|
||||||
|
ldap_set_option($ldap, LDAP_OPT_TIMELIMIT, 3);
|
||||||
|
|
||||||
|
if (!@ldap_bind($ldap, $bindDn, $bindPw)) {
|
||||||
|
throw new RuntimeException("LDAP bind failed: " . ldap_error($ldap));
|
||||||
|
}
|
||||||
|
|
||||||
|
$search = @ldap_search($ldap, $userBase, $filter, ['avatar'], 0, 1, 3);
|
||||||
|
if (!$search) {
|
||||||
|
throw new RuntimeException("LDAP search failed: " . ldap_error($ldap));
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries = ldap_get_entries($ldap, $search);
|
||||||
|
if ($entries['count'] > 0 && !empty($entries[0]['avatar'][0])) {
|
||||||
|
// ldap_get_entries() returns the attribute value as raw binary.
|
||||||
|
$avatarData = $entries[0]['avatar'][0];
|
||||||
|
}
|
||||||
|
|
||||||
|
ldap_unbind($ldap);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
error_log("user_avatar: LDAP error for username=$username: " . $e->getMessage());
|
||||||
|
// Fall through to 404
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($avatarData === null || strlen($avatarData) < 100) {
|
||||||
|
// Write sentinel so we don't hammer LDAP for users without avatars
|
||||||
|
file_put_contents($noAvatarSentinel, '');
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate it's actually a JPEG (magic bytes FF D8 FF)
|
||||||
|
if (substr($avatarData, 0, 3) !== "\xFF\xD8\xFF") {
|
||||||
|
error_log("user_avatar: non-JPEG data for username=$username");
|
||||||
|
file_put_contents($noAvatarSentinel, '');
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache to disk
|
||||||
|
file_put_contents($cacheFile, $avatarData);
|
||||||
|
// Remove stale sentinel if present
|
||||||
|
if (file_exists($noAvatarSentinel)) {
|
||||||
|
unlink($noAvatarSentinel);
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: image/jpeg');
|
||||||
|
header('Cache-Control: private, max-age=' . $cacheTtl);
|
||||||
|
header('X-Avatar-Source: ldap');
|
||||||
|
echo $avatarData;
|
||||||
+48
-68
@@ -1,109 +1,93 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User Preferences API Endpoint
|
* User Preferences API Endpoint
|
||||||
* Handles GET (fetch preferences) and POST (update preference)
|
* Handles GET (fetch preferences) and POST (update preference)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/config/config.php';
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
|
require_once dirname(__DIR__) . '/models/UserPreferencesModel.php';
|
||||||
|
|
||||||
session_start();
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
|
|
||||||
// Check authentication
|
|
||||||
if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
|
||||||
http_response_code(401);
|
|
||||||
echo json_encode(['success' => false, 'error' => 'Not authenticated']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// CSRF Protection
|
|
||||||
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
|
||||||
$csrfToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
|
|
||||||
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
|
||||||
http_response_code(403);
|
|
||||||
echo json_encode(['success' => false, 'error' => 'Invalid CSRF token']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$userId = $_SESSION['user']['user_id'];
|
|
||||||
|
|
||||||
// Create database connection
|
|
||||||
$conn = new mysqli(
|
|
||||||
$GLOBALS['config']['DB_HOST'],
|
|
||||||
$GLOBALS['config']['DB_USER'],
|
|
||||||
$GLOBALS['config']['DB_PASS'],
|
|
||||||
$GLOBALS['config']['DB_NAME']
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($conn->connect_error) {
|
|
||||||
http_response_code(500);
|
|
||||||
echo json_encode(['success' => false, 'error' => 'Database connection failed']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$prefsModel = new UserPreferencesModel($conn);
|
$prefsModel = new UserPreferencesModel($conn);
|
||||||
|
|
||||||
// GET - Fetch all preferences for user
|
// GET - Fetch all preferences for user
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||||
try {
|
try {
|
||||||
$prefs = $prefsModel->getUserPreferences($userId);
|
$prefs = $prefsModel->getUserPreferences($userId);
|
||||||
echo json_encode(['success' => true, 'preferences' => $prefs]);
|
apiRespond(['success' => true, 'preferences' => $prefs]);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'Failed to fetch preferences']);
|
apiRespond(['success' => false, 'error' => 'Failed to fetch preferences']);
|
||||||
}
|
}
|
||||||
$conn->close();
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST - Update a preference
|
// POST - Update preference(s)
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
if (!isset($data['key']) || !isset($data['value'])) {
|
|
||||||
http_response_code(400);
|
|
||||||
echo json_encode(['success' => false, 'error' => 'Missing key or value']);
|
|
||||||
$conn->close();
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$key = trim($data['key']);
|
|
||||||
$value = $data['value'];
|
|
||||||
|
|
||||||
// Validate preference key (whitelist)
|
// Validate preference key (whitelist)
|
||||||
$validKeys = [
|
$validKeys = [
|
||||||
'rows_per_page',
|
'rows_per_page',
|
||||||
'default_status_filters',
|
'default_status_filters',
|
||||||
'table_density',
|
'table_density',
|
||||||
|
'timezone',
|
||||||
'notifications_enabled',
|
'notifications_enabled',
|
||||||
'sound_effects',
|
'sound_effects',
|
||||||
'toast_duration'
|
'toast_duration',
|
||||||
|
'notif_last_seen',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Support batch save: { preferences: { key: value, ... } }
|
||||||
|
if (isset($data['preferences']) && is_array($data['preferences'])) {
|
||||||
|
try {
|
||||||
|
foreach ($data['preferences'] as $key => $value) {
|
||||||
|
$key = trim($key);
|
||||||
|
if (!in_array($key, $validKeys)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$prefsModel->setPreference($userId, $key, (string)$value);
|
||||||
|
if ($key === 'rows_per_page') {
|
||||||
|
setcookie('ticketsPerPage', $value, ['expires' => time() + (86400 * 365), 'path' => '/', 'httponly' => true, 'secure' => true, 'samesite' => 'Lax']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apiRespond(['success' => true]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
apiRespond(['success' => false, 'error' => 'Failed to save preferences']);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single preference: { key, value }
|
||||||
|
if (!isset($data['key']) || !isset($data['value'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
apiRespond(['success' => false, 'error' => 'Missing key or value']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = trim($data['key']);
|
||||||
|
$value = $data['value'];
|
||||||
|
|
||||||
if (!in_array($key, $validKeys)) {
|
if (!in_array($key, $validKeys)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Invalid preference key']);
|
apiRespond(['success' => false, 'error' => 'Invalid preference key']);
|
||||||
$conn->close();
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$success = $prefsModel->setPreference($userId, $key, $value);
|
$success = $prefsModel->setPreference($userId, $key, (string)$value);
|
||||||
|
|
||||||
// Also update cookie for rows_per_page for backwards compatibility
|
// Also update cookie for rows_per_page for backwards compatibility
|
||||||
if ($key === 'rows_per_page') {
|
if ($key === 'rows_per_page') {
|
||||||
setcookie('ticketsPerPage', $value, time() + (86400 * 365), '/');
|
setcookie('ticketsPerPage', (string)$value, ['expires' => time() + (86400 * 365), 'path' => '/', 'httponly' => true, 'secure' => true, 'samesite' => 'Lax']);
|
||||||
}
|
}
|
||||||
|
|
||||||
echo json_encode(['success' => $success]);
|
apiRespond(['success' => $success]);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'Failed to save preference']);
|
apiRespond(['success' => false, 'error' => 'Failed to save preference']);
|
||||||
}
|
}
|
||||||
$conn->close();
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,24 +97,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
|||||||
|
|
||||||
if (!isset($data['key'])) {
|
if (!isset($data['key'])) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
echo json_encode(['success' => false, 'error' => 'Missing key']);
|
apiRespond(['success' => false, 'error' => 'Missing key']);
|
||||||
$conn->close();
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$success = $prefsModel->deletePreference($userId, $data['key']);
|
$success = $prefsModel->deletePreference($userId, $data['key']);
|
||||||
echo json_encode(['success' => $success]);
|
apiRespond(['success' => $success]);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
http_response_code(500);
|
http_response_code(500);
|
||||||
echo json_encode(['success' => false, 'error' => 'Failed to delete preference']);
|
apiRespond(['success' => false, 'error' => 'Failed to delete preference']);
|
||||||
}
|
}
|
||||||
$conn->close();
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Method not allowed
|
// Method not allowed
|
||||||
http_response_code(405);
|
http_response_code(405);
|
||||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
apiRespond(['success' => false, 'error' => 'Method not allowed']);
|
||||||
$conn->close();
|
|
||||||
?>
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch / Unwatch Ticket API
|
||||||
|
*
|
||||||
|
* GET ?ticket_id=N → returns { watching: bool, watcher_count: int }
|
||||||
|
* POST { ticket_id, action: 'watch'|'unwatch' } → toggles watcher row
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
|
||||||
|
$ticketId = isset($_GET['ticket_id'])
|
||||||
|
? (int)$_GET['ticket_id']
|
||||||
|
: (isset($data['ticket_id']) ? (int)$data['ticket_id'] : 0);
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$data = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
$ticketId = (int)($data['ticket_id'] ?? 0);
|
||||||
|
$action = $data['action'] ?? '';
|
||||||
|
|
||||||
|
if ($ticketId <= 0 || !in_array($action, ['watch', 'unwatch'], true)) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Invalid parameters']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
$ticket = $ticketModel->getTicketById($ticketId);
|
||||||
|
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'watch') {
|
||||||
|
$stmt = $conn->prepare(
|
||||||
|
"INSERT IGNORE INTO ticket_watchers (ticket_id, user_id) VALUES (?, ?)"
|
||||||
|
);
|
||||||
|
$stmt->bind_param("ii", $ticketId, $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$stmt->close();
|
||||||
|
} else {
|
||||||
|
$stmt = $conn->prepare(
|
||||||
|
"DELETE FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
|
||||||
|
);
|
||||||
|
$stmt->bind_param("ii", $ticketId, $userId);
|
||||||
|
$stmt->execute();
|
||||||
|
$stmt->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return updated state
|
||||||
|
$countStmt = $conn->prepare(
|
||||||
|
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ?"
|
||||||
|
);
|
||||||
|
$countStmt->bind_param("i", $ticketId);
|
||||||
|
$countStmt->execute();
|
||||||
|
$count = (int)$countStmt->get_result()->fetch_assoc()['cnt'];
|
||||||
|
$countStmt->close();
|
||||||
|
|
||||||
|
apiRespond([
|
||||||
|
'success' => true,
|
||||||
|
'watching' => $action === 'watch',
|
||||||
|
'watcher_count' => $count,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET — return current watch state for this user
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ticketId <= 0) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'ticket_id required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$watchingStmt = $conn->prepare(
|
||||||
|
"SELECT COUNT(*) as cnt FROM ticket_watchers WHERE ticket_id = ? AND user_id = ?"
|
||||||
|
);
|
||||||
|
$watchingStmt->bind_param("ii", $ticketId, $userId);
|
||||||
|
$watchingStmt->execute();
|
||||||
|
$watching = (bool)$watchingStmt->get_result()->fetch_assoc()['cnt'];
|
||||||
|
$watchingStmt->close();
|
||||||
|
|
||||||
|
// Fetch watcher list (up to 6) with display names for avatar group
|
||||||
|
$watchersStmt = $conn->prepare(
|
||||||
|
"SELECT u.user_id, COALESCE(u.display_name, u.username) AS display_name
|
||||||
|
FROM ticket_watchers tw
|
||||||
|
JOIN users u ON tw.user_id = u.user_id
|
||||||
|
WHERE tw.ticket_id = ?
|
||||||
|
ORDER BY tw.created_at ASC
|
||||||
|
LIMIT 6"
|
||||||
|
);
|
||||||
|
$watchersStmt->bind_param("i", $ticketId);
|
||||||
|
$watchersStmt->execute();
|
||||||
|
$watchersResult = $watchersStmt->get_result();
|
||||||
|
$watchers = [];
|
||||||
|
while ($row = $watchersResult->fetch_assoc()) {
|
||||||
|
$watchers[] = ['user_id' => (int)$row['user_id'], 'display_name' => $row['display_name']];
|
||||||
|
}
|
||||||
|
$watchersStmt->close();
|
||||||
|
$count = count($watchers);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'watching' => $watching,
|
||||||
|
'watcher_count' => $count,
|
||||||
|
'watchers' => $watchers,
|
||||||
|
]);
|
||||||
+5716
File diff suppressed because it is too large
Load Diff
+421
-2969
File diff suppressed because it is too large
Load Diff
+328
-1138
File diff suppressed because it is too large
Load Diff
+48
-102
@@ -7,8 +7,7 @@
|
|||||||
function openAdvancedSearch() {
|
function openAdvancedSearch() {
|
||||||
const modal = document.getElementById('advancedSearchModal');
|
const modal = document.getElementById('advancedSearchModal');
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.style.display = 'flex';
|
lt.modal.open('advancedSearchModal');
|
||||||
document.body.classList.add('modal-open');
|
|
||||||
loadUsersForSearch();
|
loadUsersForSearch();
|
||||||
populateCurrentFilters();
|
populateCurrentFilters();
|
||||||
loadSavedFilters();
|
loadSavedFilters();
|
||||||
@@ -17,26 +16,13 @@ function openAdvancedSearch() {
|
|||||||
|
|
||||||
// Close advanced search modal
|
// Close advanced search modal
|
||||||
function closeAdvancedSearch() {
|
function closeAdvancedSearch() {
|
||||||
const modal = document.getElementById('advancedSearchModal');
|
lt.modal.close('advancedSearchModal');
|
||||||
if (modal) {
|
|
||||||
modal.style.display = 'none';
|
|
||||||
document.body.classList.remove('modal-open');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal when clicking on backdrop
|
|
||||||
function closeOnAdvancedSearchBackdropClick(event) {
|
|
||||||
const modal = document.getElementById('advancedSearchModal');
|
|
||||||
if (event.target === modal) {
|
|
||||||
closeAdvancedSearch();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load users for dropdown
|
// Load users for dropdown
|
||||||
async function loadUsersForSearch() {
|
async function loadUsersForSearch() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/get_users.php');
|
const data = await lt.api.get('/api/get_users.php');
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success && data.users) {
|
if (data.success && data.users) {
|
||||||
const createdBySelect = document.getElementById('adv-created-by');
|
const createdBySelect = document.getElementById('adv-created-by');
|
||||||
@@ -66,7 +52,7 @@ async function loadUsersForSearch() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading users:', error);
|
lt.toast.error('Error loading users');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,42 +134,30 @@ function resetAdvancedSearch() {
|
|||||||
|
|
||||||
// Save current search as a filter
|
// Save current search as a filter
|
||||||
async function saveCurrentFilter() {
|
async function saveCurrentFilter() {
|
||||||
const filterName = prompt('Enter a name for this filter:');
|
showInputModal(
|
||||||
if (!filterName || filterName.trim() === '') return;
|
'Save Search Filter',
|
||||||
|
'Enter a name for this filter:',
|
||||||
const filterCriteria = getCurrentFilterCriteria();
|
'My Filter',
|
||||||
|
async (filterName) => {
|
||||||
try {
|
if (!filterName || filterName.trim() === '') {
|
||||||
const response = await fetch('/api/saved_filters.php', {
|
lt.toast.warning('Filter name cannot be empty', 2000);
|
||||||
method: 'POST',
|
return;
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
filter_name: filterName.trim(),
|
|
||||||
filter_criteria: filterCriteria
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
if (typeof toast !== 'undefined') {
|
|
||||||
toast.success(`Filter "${filterName}" saved successfully!`);
|
|
||||||
}
|
}
|
||||||
loadSavedFilters();
|
|
||||||
} else {
|
const filterCriteria = getCurrentFilterCriteria();
|
||||||
if (typeof toast !== 'undefined') {
|
|
||||||
toast.error('Failed to save filter: ' + (result.error || 'Unknown error'));
|
try {
|
||||||
|
await lt.api.post('/api/saved_filters.php', {
|
||||||
|
filter_name: filterName.trim(),
|
||||||
|
filter_criteria: filterCriteria
|
||||||
|
});
|
||||||
|
lt.toast.success(`Filter "${filterName}" saved successfully!`, 3000);
|
||||||
|
loadSavedFilters();
|
||||||
|
} catch (error) {
|
||||||
|
lt.toast.error('Error saving filter: ' + (error.message || 'Unknown error'), 4000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
);
|
||||||
console.error('Error saving filter:', error);
|
|
||||||
if (typeof toast !== 'undefined') {
|
|
||||||
toast.error('Error saving filter');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current filter criteria from form
|
// Get current filter criteria from form
|
||||||
@@ -207,7 +181,7 @@ function getCurrentFilterCriteria() {
|
|||||||
|
|
||||||
const statusSelect = document.getElementById('adv-status');
|
const statusSelect = document.getElementById('adv-status');
|
||||||
const selectedStatuses = Array.from(statusSelect.selectedOptions).map(opt => opt.value);
|
const selectedStatuses = Array.from(statusSelect.selectedOptions).map(opt => opt.value);
|
||||||
if (selectedStatuses.length > 0) criteria.status = selectedStatuses.join(',');
|
if (selectedStatuses.length > 0) criteria.status = selectedStatuses; // keep as array — pill handler uses .join(',')
|
||||||
|
|
||||||
const priorityMin = document.getElementById('adv-priority-min').value;
|
const priorityMin = document.getElementById('adv-priority-min').value;
|
||||||
if (priorityMin) criteria.priority_min = priorityMin;
|
if (priorityMin) criteria.priority_min = priorityMin;
|
||||||
@@ -227,14 +201,12 @@ function getCurrentFilterCriteria() {
|
|||||||
// Load saved filters
|
// Load saved filters
|
||||||
async function loadSavedFilters() {
|
async function loadSavedFilters() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/saved_filters.php');
|
const data = await lt.api.get('/api/saved_filters.php');
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success && data.filters) {
|
if (data.success && data.filters) {
|
||||||
populateSavedFiltersDropdown(data.filters);
|
populateSavedFiltersDropdown(data.filters);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading saved filters:', error);
|
lt.toast.error('Error loading saved filters');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,7 +241,7 @@ function loadSavedFilter() {
|
|||||||
const criteria = JSON.parse(selectedOption.dataset.criteria);
|
const criteria = JSON.parse(selectedOption.dataset.criteria);
|
||||||
applySavedFilterCriteria(criteria);
|
applySavedFilterCriteria(criteria);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading filter:', error);
|
lt.toast.error('Error loading filter');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,9 +256,11 @@ function applySavedFilterCriteria(criteria) {
|
|||||||
document.getElementById('adv-updated-from').value = criteria.updated_from || '';
|
document.getElementById('adv-updated-from').value = criteria.updated_from || '';
|
||||||
document.getElementById('adv-updated-to').value = criteria.updated_to || '';
|
document.getElementById('adv-updated-to').value = criteria.updated_to || '';
|
||||||
|
|
||||||
// Status
|
// Status — criteria.status may be an array (new saves) or a comma-joined string (old saves)
|
||||||
const statusSelect = document.getElementById('adv-status');
|
const statusSelect = document.getElementById('adv-status');
|
||||||
const statuses = criteria.status ? criteria.status.split(',') : [];
|
const statuses = criteria.status
|
||||||
|
? (Array.isArray(criteria.status) ? criteria.status : criteria.status.split(','))
|
||||||
|
: [];
|
||||||
Array.from(statusSelect.options).forEach(option => {
|
Array.from(statusSelect.options).forEach(option => {
|
||||||
option.selected = statuses.includes(option.value);
|
option.selected = statuses.includes(option.value);
|
||||||
});
|
});
|
||||||
@@ -306,62 +280,34 @@ async function deleteSavedFilter() {
|
|||||||
const selectedOption = dropdown.options[dropdown.selectedIndex];
|
const selectedOption = dropdown.options[dropdown.selectedIndex];
|
||||||
|
|
||||||
if (!selectedOption || selectedOption.value === '') {
|
if (!selectedOption || selectedOption.value === '') {
|
||||||
if (typeof toast !== 'undefined') {
|
lt.toast.error('Please select a filter to delete');
|
||||||
toast.error('Please select a filter to delete');
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterId = selectedOption.value;
|
const filterId = selectedOption.value;
|
||||||
const filterName = selectedOption.textContent;
|
const filterName = selectedOption.textContent;
|
||||||
|
|
||||||
if (!confirm(`Are you sure you want to delete the filter "${filterName}"?`)) {
|
showConfirmModal(
|
||||||
return;
|
`Delete Filter "${filterName}"?`,
|
||||||
}
|
'This action cannot be undone.',
|
||||||
|
'error',
|
||||||
try {
|
async () => {
|
||||||
const response = await fetch('/api/saved_filters.php', {
|
try {
|
||||||
method: 'DELETE',
|
await lt.api.delete('/api/saved_filters.php', { filter_id: filterId });
|
||||||
headers: {
|
lt.toast.success('Filter deleted successfully', 3000);
|
||||||
'Content-Type': 'application/json',
|
loadSavedFilters();
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
resetAdvancedSearch();
|
||||||
},
|
} catch (error) {
|
||||||
body: JSON.stringify({ filter_id: filterId })
|
lt.toast.error('Error deleting filter', 4000);
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
if (typeof toast !== 'undefined') {
|
|
||||||
toast.success('Filter deleted successfully');
|
|
||||||
}
|
|
||||||
loadSavedFilters();
|
|
||||||
resetAdvancedSearch();
|
|
||||||
} else {
|
|
||||||
if (typeof toast !== 'undefined') {
|
|
||||||
toast.error('Failed to delete filter');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
);
|
||||||
console.error('Error deleting filter:', error);
|
|
||||||
if (typeof toast !== 'undefined') {
|
|
||||||
toast.error('Error deleting filter');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyboard shortcut (Ctrl+Shift+F)
|
// Keyboard shortcut (Ctrl+Shift+F) — ESC is handled globally by lt.keys.initDefaults()
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (e.ctrlKey && e.shiftKey && e.key === 'F') {
|
if (e.ctrlKey && e.shiftKey && e.key === 'F') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openAdvancedSearch();
|
openAdvancedSearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ESC to close
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
const modal = document.getElementById('advancedSearchModal');
|
|
||||||
if (modal && modal.style.display === 'flex') {
|
|
||||||
closeAdvancedSearch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,26 +60,13 @@ function renderASCIIBanner(bannerId, containerSelector, speed = 5, addGlow = tru
|
|||||||
const container = document.querySelector(containerSelector);
|
const container = document.querySelector(containerSelector);
|
||||||
|
|
||||||
if (!container || !banner) {
|
if (!container || !banner) {
|
||||||
console.error('ASCII Banner: Container or banner not found', { bannerId, containerSelector });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create pre element for ASCII art
|
// Create pre element for ASCII art
|
||||||
const pre = document.createElement('pre');
|
const pre = document.createElement('pre');
|
||||||
pre.className = 'ascii-banner';
|
pre.className = addGlow ? 'ascii-banner ascii-banner--glow' : 'ascii-banner';
|
||||||
pre.style.margin = '0';
|
|
||||||
pre.style.fontFamily = 'var(--font-mono)';
|
|
||||||
pre.style.color = 'var(--terminal-green)';
|
|
||||||
|
|
||||||
if (addGlow) {
|
|
||||||
pre.style.textShadow = 'var(--glow-green)';
|
|
||||||
}
|
|
||||||
|
|
||||||
pre.style.fontSize = getBannerFontSize(bannerId);
|
pre.style.fontSize = getBannerFontSize(bannerId);
|
||||||
pre.style.lineHeight = '1.2';
|
|
||||||
pre.style.whiteSpace = 'pre';
|
|
||||||
pre.style.overflow = 'visible';
|
|
||||||
pre.style.textAlign = 'center';
|
|
||||||
|
|
||||||
container.appendChild(pre);
|
container.appendChild(pre);
|
||||||
|
|
||||||
@@ -179,8 +166,7 @@ function animatedWelcome(containerSelector) {
|
|||||||
banner.addEventListener('bannerComplete', () => {
|
banner.addEventListener('bannerComplete', () => {
|
||||||
const cursor = document.createElement('span');
|
const cursor = document.createElement('span');
|
||||||
cursor.textContent = '█';
|
cursor.textContent = '█';
|
||||||
cursor.style.animation = 'blink-caret 0.75s step-end infinite';
|
cursor.className = 'ascii-banner-cursor';
|
||||||
cursor.style.marginLeft = '5px';
|
|
||||||
banner.appendChild(cursor);
|
banner.appendChild(cursor);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+2950
File diff suppressed because it is too large
Load Diff
+1257
-586
File diff suppressed because it is too large
Load Diff
+104
-73
@@ -1,81 +1,112 @@
|
|||||||
/**
|
/**
|
||||||
* Keyboard shortcuts for power users
|
* Keyboard shortcuts for power users.
|
||||||
|
* App-specific shortcuts registered via lt.keys.on() from web_template/base.js.
|
||||||
|
* ESC, Ctrl+K, and ? are handled by lt.keys.initDefaults().
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Track currently selected row for J/K navigation
|
||||||
|
let currentSelectedRowIndex = -1;
|
||||||
|
|
||||||
|
function navigateTableRow(direction) {
|
||||||
|
const rows = document.querySelectorAll('tbody tr');
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
|
||||||
|
rows.forEach(row => row.classList.remove('keyboard-selected'));
|
||||||
|
|
||||||
|
if (direction === 'next') {
|
||||||
|
currentSelectedRowIndex = Math.min(currentSelectedRowIndex + 1, rows.length - 1);
|
||||||
|
} else {
|
||||||
|
currentSelectedRowIndex = Math.max(currentSelectedRowIndex - 1, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedRow = rows[currentSelectedRowIndex];
|
||||||
|
if (selectedRow) {
|
||||||
|
selectedRow.classList.add('keyboard-selected');
|
||||||
|
selectedRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
document.addEventListener('keydown', function(e) {
|
if (!window.lt) return;
|
||||||
// Skip if user is typing in an input/textarea
|
|
||||||
if (e.target.tagName === 'INPUT' ||
|
|
||||||
e.target.tagName === 'TEXTAREA' ||
|
|
||||||
e.target.isContentEditable) {
|
|
||||||
// Allow ESC to exit edit mode even when in input
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
e.target.blur();
|
|
||||||
const editButton = document.getElementById('editButton');
|
|
||||||
if (editButton && editButton.classList.contains('active')) {
|
|
||||||
editButton.click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl/Cmd + E: Toggle edit mode (on ticket pages)
|
// Ctrl+E: Toggle edit mode (ticket pages)
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'e') {
|
lt.keys.on('ctrl+e', function() {
|
||||||
e.preventDefault();
|
const editButton = document.getElementById('editButton');
|
||||||
const editButton = document.getElementById('editButton');
|
if (editButton) {
|
||||||
if (editButton) {
|
editButton.click();
|
||||||
editButton.click();
|
lt.toast.info('Edit mode ' + (editButton.classList.contains('active') ? 'enabled' : 'disabled'));
|
||||||
toast.info('Edit mode ' + (editButton.classList.contains('active') ? 'enabled' : 'disabled'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl/Cmd + S: Save ticket (on ticket pages)
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
||||||
e.preventDefault();
|
|
||||||
const editButton = document.getElementById('editButton');
|
|
||||||
if (editButton && editButton.classList.contains('active')) {
|
|
||||||
editButton.click();
|
|
||||||
toast.success('Saving ticket...');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ESC: Cancel edit mode
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
const editButton = document.getElementById('editButton');
|
|
||||||
if (editButton && editButton.classList.contains('active')) {
|
|
||||||
// Reset without saving
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl/Cmd + K: Focus search (on dashboard)
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
|
||||||
e.preventDefault();
|
|
||||||
const searchBox = document.querySelector('.search-box');
|
|
||||||
if (searchBox) {
|
|
||||||
searchBox.focus();
|
|
||||||
searchBox.select();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ? : Show keyboard shortcuts help
|
|
||||||
if (e.key === '?' && !e.shiftKey) {
|
|
||||||
showKeyboardHelp();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
function showKeyboardHelp() {
|
// Ctrl+S: Save ticket (ticket pages)
|
||||||
const helpText = `
|
lt.keys.on('ctrl+s', function() {
|
||||||
╔════════════════════════════════════════╗
|
const editButton = document.getElementById('editButton');
|
||||||
║ KEYBOARD SHORTCUTS ║
|
if (editButton && editButton.classList.contains('active')) {
|
||||||
╠════════════════════════════════════════╣
|
editButton.click();
|
||||||
║ Ctrl/Cmd + E : Toggle Edit Mode ║
|
lt.toast.success('Saving ticket...');
|
||||||
║ Ctrl/Cmd + S : Save Changes ║
|
}
|
||||||
║ Ctrl/Cmd + K : Focus Search ║
|
});
|
||||||
║ ESC : Cancel Edit/Close ║
|
|
||||||
║ ? : Show This Help ║
|
// ?: Show keyboard shortcuts help — use the static #lt-keys-help modal in the footer
|
||||||
╚════════════════════════════════════════╝
|
lt.keys.on('?', function() {
|
||||||
`;
|
if (window.lt) lt.modal.open('lt-keys-help');
|
||||||
toast.info(helpText, 5000);
|
});
|
||||||
}
|
|
||||||
|
// J: Next row
|
||||||
|
lt.keys.on('j', () => navigateTableRow('next'));
|
||||||
|
|
||||||
|
// K: Previous row
|
||||||
|
lt.keys.on('k', () => navigateTableRow('prev'));
|
||||||
|
|
||||||
|
// Enter: Open selected ticket
|
||||||
|
lt.keys.on('enter', function() {
|
||||||
|
const selectedRow = document.querySelector('tbody tr.keyboard-selected');
|
||||||
|
if (selectedRow) {
|
||||||
|
const ticketLink = selectedRow.querySelector('a[href*="/ticket/"]');
|
||||||
|
if (ticketLink) window.location.href = ticketLink.href;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// N: New ticket
|
||||||
|
lt.keys.on('n', function() {
|
||||||
|
const newTicketBtn = document.querySelector('a[href*="/create"]');
|
||||||
|
if (newTicketBtn) window.location.href = newTicketBtn.href;
|
||||||
|
});
|
||||||
|
|
||||||
|
// C: Focus comment box
|
||||||
|
lt.keys.on('c', function() {
|
||||||
|
const commentBox = document.getElementById('newComment');
|
||||||
|
if (commentBox) {
|
||||||
|
commentBox.focus();
|
||||||
|
commentBox.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// G then D: Go to Dashboard (vim-style)
|
||||||
|
lt.keys.on('g', function() {
|
||||||
|
window._pendingG = true;
|
||||||
|
setTimeout(() => { window._pendingG = false; }, 1000);
|
||||||
|
});
|
||||||
|
lt.keys.on('d', function() {
|
||||||
|
if (window._pendingG) {
|
||||||
|
window._pendingG = false;
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1-4: Quick status change on ticket page
|
||||||
|
['1', '2', '3', '4'].forEach(key => {
|
||||||
|
lt.keys.on(key, function() {
|
||||||
|
const statusSelect = document.getElementById('statusSelect');
|
||||||
|
if (statusSelect && !document.querySelector('.lt-modal-overlay[aria-hidden="false"]')) {
|
||||||
|
const statusMap = { '1': 'Open', '2': 'Pending', '3': 'In Progress', '4': 'Closed' };
|
||||||
|
const targetStatus = statusMap[key];
|
||||||
|
const option = Array.from(statusSelect.options).find(opt => opt.value === targetStatus);
|
||||||
|
if (option && !option.disabled) {
|
||||||
|
statusSelect.value = targetStatus;
|
||||||
|
statusSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+509
-17
@@ -6,6 +6,23 @@
|
|||||||
function parseMarkdown(markdown) {
|
function parseMarkdown(markdown) {
|
||||||
if (!markdown) return '';
|
if (!markdown) return '';
|
||||||
|
|
||||||
|
// Footnotes — collect definitions and mark references with placeholders
|
||||||
|
// (must happen before HTML escaping so <sup> tags don't get escaped)
|
||||||
|
const footnotes = {};
|
||||||
|
const footnoteOrder = [];
|
||||||
|
const fnRefs = [];
|
||||||
|
markdown = markdown.replace(/^\[\^([^\]]+)\]:\s+(.+)$/gm, function(_, label, text) {
|
||||||
|
footnotes[label] = text;
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
markdown = markdown.replace(/\[\^([^\]]+)\]/g, function(_, label) {
|
||||||
|
if (!footnotes[label]) return '[^' + label + ']';
|
||||||
|
if (!footnoteOrder.includes(label)) footnoteOrder.push(label);
|
||||||
|
const n = footnoteOrder.indexOf(label) + 1;
|
||||||
|
fnRefs.push({ label, n });
|
||||||
|
return '%%FNREF' + (fnRefs.length - 1) + '%%';
|
||||||
|
});
|
||||||
|
|
||||||
let html = markdown;
|
let html = markdown;
|
||||||
|
|
||||||
// Escape HTML first to prevent XSS
|
// Escape HTML first to prevent XSS
|
||||||
@@ -13,11 +30,28 @@ function parseMarkdown(markdown) {
|
|||||||
.replace(/</g, '<')
|
.replace(/</g, '<')
|
||||||
.replace(/>/g, '>');
|
.replace(/>/g, '>');
|
||||||
|
|
||||||
// Code blocks (```code```)
|
// Ticket references (#123456789) - convert to clickable links
|
||||||
html = html.replace(/```([\s\S]*?)```/g, '<pre class="code-block"><code>$1</code></pre>');
|
html = html.replace(/#(\d{9})\b/g, '<a href="/ticket/$1" class="ticket-link-ref">#$1</a>');
|
||||||
|
|
||||||
// Inline code (`code`)
|
// Code blocks (```code```) - preserve content and don't process further
|
||||||
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>');
|
const codeBlocks = [];
|
||||||
|
html = html.replace(/```([\s\S]*?)```/g, function(match, code) {
|
||||||
|
codeBlocks.push('<pre class="code-block"><code>' + code + '</code></pre>');
|
||||||
|
return '%%CODEBLOCK' + (codeBlocks.length - 1) + '%%';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inline code (`code`) - preserve and don't process further
|
||||||
|
const inlineCodes = [];
|
||||||
|
html = html.replace(/`([^`]+)`/g, function(match, code) {
|
||||||
|
inlineCodes.push('<code class="inline-code">' + code + '</code>');
|
||||||
|
return '%%INLINECODE' + (inlineCodes.length - 1) + '%%';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tables (must be processed before other block elements)
|
||||||
|
html = parseMarkdownTables(html);
|
||||||
|
|
||||||
|
// Emoji :name: — common set
|
||||||
|
html = replaceEmoji(html);
|
||||||
|
|
||||||
// Bold (**text** or __text__)
|
// Bold (**text** or __text__)
|
||||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||||
@@ -27,24 +61,68 @@ function parseMarkdown(markdown) {
|
|||||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||||
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
|
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
|
||||||
|
|
||||||
// Links [text](url)
|
// Strikethrough (~~text~~) — must run before subscript (~)
|
||||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');
|
||||||
|
|
||||||
// Headers (# H1, ## H2, etc.)
|
// Highlight (==text==)
|
||||||
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
html = html.replace(/==(.+?)==/g, '<mark>$1</mark>');
|
||||||
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
|
||||||
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
|
||||||
|
|
||||||
// Lists
|
// Subscript H~2~O — single tilde (not preceded/followed by another tilde)
|
||||||
// Unordered lists (- item or * item)
|
html = html.replace(/(?<!~)~([^~\n]+?)~(?!~)/g, '<sub>$1</sub>');
|
||||||
html = html.replace(/^\s*[-*]\s+(.+)$/gm, '<li>$1</li>');
|
|
||||||
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
|
|
||||||
|
|
||||||
// Ordered lists (1. item)
|
// Superscript X^2^ — caret pair
|
||||||
html = html.replace(/^\s*\d+\.\s+(.+)$/gm, '<li>$1</li>');
|
html = html.replace(/\^([^\^\n]+?)\^/g, '<sup>$1</sup>');
|
||||||
|
|
||||||
|
// Images  - must come before link handler
|
||||||
|
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function(match, alt, url) {
|
||||||
|
if (/^https?:/i.test(url)) {
|
||||||
|
return '<img src="' + url + '" alt="' + alt + '" class="md-image" loading="lazy">';
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Links [text](url) - only allow safe protocols
|
||||||
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(match, text, url) {
|
||||||
|
// Only allow http, https, mailto protocols
|
||||||
|
if (/^(https?:|mailto:|\/)/i.test(url)) {
|
||||||
|
return '<a href="' + url + '" target="_blank" rel="noopener noreferrer">' + text + '</a>';
|
||||||
|
}
|
||||||
|
// Block potentially dangerous protocols (javascript:, data:, etc.)
|
||||||
|
return text;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-link bare URLs (http, https)
|
||||||
|
html = html.replace(/(?<!["\'>])(\bhttps?:\/\/[^\s<>\[\]()]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||||
|
|
||||||
|
// Headings with optional {#id} anchor — ### My Heading {#my-id}
|
||||||
|
html = html.replace(/^(#{1,6})\s+(.+?)\s*(?:\{#([a-z0-9_-]+)\})?$/gm, function(match, hashes, text, id) {
|
||||||
|
const level = hashes.length;
|
||||||
|
const idAttr = id ? ' id="' + id + '"' : '';
|
||||||
|
return '<h' + level + idAttr + '>' + text + '</h' + level + '>';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lists — tag each item type with a placeholder, then wrap consecutive runs
|
||||||
|
html = html.replace(/^\s*\d+\.\s+(.+)$/gm, '%%OLI%%$1');
|
||||||
|
html = html.replace(/^\s*[-*+]\s+\[x\]\s+(.+)$/gim, '%%TDI%%$1');
|
||||||
|
html = html.replace(/^\s*[-*+]\s+\[ \]\s+(.+)$/gm, '%%TTI%%$1');
|
||||||
|
html = html.replace(/^\s*[-*+]\s+(.+)$/gm, '%%ULI%%$1');
|
||||||
|
|
||||||
|
// Wrap consecutive ordered items in <ol>
|
||||||
|
html = html.replace(/(%%OLI%%.+(\n%%OLI%%.+)*)/g, function(block) {
|
||||||
|
return '<ol>' + block.replace(/%%OLI%%(.+)/g, '<li>$1</li>') + '</ol>';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrap consecutive unordered/task items in <ul>
|
||||||
|
html = html.replace(/((?:%%(?:ULI|TDI|TTI)%%).+(?:\n(?:%%(?:ULI|TDI|TTI)%%).+)*)/g, function(block) {
|
||||||
|
return '<ul>' + block
|
||||||
|
.replace(/%%ULI%%(.+)/g, '<li>$1</li>')
|
||||||
|
.replace(/%%TDI%%(.+)/g, '<li class="task-item task-done"><span class="task-cb">☑</span> $1</li>')
|
||||||
|
.replace(/%%TTI%%(.+)/g, '<li class="task-item task-todo"><span class="task-cb">☐</span> $1</li>')
|
||||||
|
+ '</ul>';
|
||||||
|
});
|
||||||
|
|
||||||
// Blockquotes (> text)
|
// Blockquotes (> text)
|
||||||
html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>');
|
html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>');
|
||||||
|
|
||||||
// Horizontal rules (--- or ***)
|
// Horizontal rules (--- or ***)
|
||||||
html = html.replace(/^(?:---|___|\*\*\*)$/gm, '<hr>');
|
html = html.replace(/^(?:---|___|\*\*\*)$/gm, '<hr>');
|
||||||
@@ -53,11 +131,168 @@ function parseMarkdown(markdown) {
|
|||||||
html = html.replace(/ \n/g, '<br>');
|
html = html.replace(/ \n/g, '<br>');
|
||||||
html = html.replace(/\n\n/g, '</p><p>');
|
html = html.replace(/\n\n/g, '</p><p>');
|
||||||
|
|
||||||
|
// Restore code blocks and inline code
|
||||||
|
codeBlocks.forEach((block, i) => {
|
||||||
|
html = html.replace('%%CODEBLOCK' + i + '%%', block);
|
||||||
|
});
|
||||||
|
inlineCodes.forEach((code, i) => {
|
||||||
|
html = html.replace('%%INLINECODE' + i + '%%', code);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore footnote reference placeholders
|
||||||
|
fnRefs.forEach(function(ref, i) {
|
||||||
|
html = html.replace('%%FNREF' + i + '%%',
|
||||||
|
'<sup class="fn-ref"><a href="#fn-' + ref.label + '" id="fnref-' + ref.label + '">[' + ref.n + ']</a></sup>');
|
||||||
|
});
|
||||||
|
|
||||||
// Wrap in paragraph if not already wrapped
|
// Wrap in paragraph if not already wrapped
|
||||||
if (!html.startsWith('<')) {
|
if (!html.startsWith('<')) {
|
||||||
html = '<p>' + html + '</p>';
|
html = '<p>' + html + '</p>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Append footnote definitions block
|
||||||
|
if (footnoteOrder.length) {
|
||||||
|
html += '<hr class="fn-hr"><ol class="fn-list">';
|
||||||
|
footnoteOrder.forEach(function(label, i) {
|
||||||
|
html += '<li id="fn-' + label + '" class="fn-item">' +
|
||||||
|
parseMarkdown(footnotes[label]).replace(/<\/?p>/g, '') +
|
||||||
|
' <a href="#fnref-' + label + '" class="fn-back">↩</a></li>';
|
||||||
|
});
|
||||||
|
html += '</ol>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace :emoji: shortcodes with Unicode characters
|
||||||
|
*/
|
||||||
|
const _emojiMap = {
|
||||||
|
// Faces & emotions
|
||||||
|
smile: '😊', grin: '😁', joy: '😂', rofl: '🤣', smiley: '😃', sweat_smile: '😅',
|
||||||
|
wink: '😉', blush: '😊', heart_eyes: '😍', kissing: '😗', thinking: '🤔',
|
||||||
|
raised_eyebrow: '🤨', neutral_face: '😐', expressionless: '😑', unamused: '😒',
|
||||||
|
roll_eyes: '🙄', pensive: '😔', confused: '😕', worried: '😟', cry: '😢',
|
||||||
|
sob: '😭', scream: '😱', angry: '😠', rage: '😡', skull: '💀', sunglasses: '😎',
|
||||||
|
nerd: '🤓', monocle: '🧐', clown: '🤡', ghost: '👻', robot: '🤖', alien: '👽',
|
||||||
|
// Hands & people
|
||||||
|
thumbsup: '👍', '+1': '👍', thumbsdown: '👎', '-1': '👎', clap: '👏',
|
||||||
|
wave: '👋', raised_hands: '🙌', pray: '🙏', point_up: '☝️', point_right: '👉',
|
||||||
|
point_left: '👈', point_down: '👇', fist: '✊', punch: '👊', v: '✌️', ok_hand: '👌',
|
||||||
|
muscle: '💪', eyes: '👀', eye: '👁️', ear: '👂', brain: '🧠', man: '👨', woman: '👩',
|
||||||
|
// Hearts & symbols
|
||||||
|
heart: '❤️', orange_heart: '🧡', yellow_heart: '💛', green_heart: '💚',
|
||||||
|
blue_heart: '💙', purple_heart: '💜', black_heart: '🖤', broken_heart: '💔',
|
||||||
|
star: '⭐', star2: '🌟', sparkles: '✨', fire: '🔥', boom: '💥', zap: '⚡',
|
||||||
|
check: '✅', white_check_mark: '✅', x: '❌', heavy_check_mark: '✔️',
|
||||||
|
warning: '⚠️', no_entry: '⛔', stop_sign: '🛑', prohibited: '🚫',
|
||||||
|
question: '❓', exclamation: '❗', grey_question: '❔', grey_exclamation: '❕',
|
||||||
|
100: '💯', tada: '🎉', confetti_ball: '🎊', trophy: '🏆', medal: '🥇',
|
||||||
|
// Tech & work
|
||||||
|
bug: '🐛', rocket: '🚀', computer: '💻', keyboard: '⌨️', mouse: '🖱️',
|
||||||
|
printer: '🖨️', phone: '📱', email: '📧', inbox_tray: '📥', outbox_tray: '📤',
|
||||||
|
memo: '📝', pencil: '✏️', pen: '🖊️', paperclip: '📎', link: '🔗',
|
||||||
|
hammer: '🔨', wrench: '🔧', gear: '⚙️', lock: '🔒', unlock: '🔓',
|
||||||
|
key: '🔑', mag: '🔍', bar_chart: '📊', chart_increasing: '📈', chart_decreasing: '📉',
|
||||||
|
clipboard: '📋', calendar: '📅', clock: '🕐', hourglass: '⏳', bell: '🔔',
|
||||||
|
mute: '🔇', loud_sound: '🔊', bulb: '💡', battery: '🔋', electric_plug: '🔌',
|
||||||
|
recycle: '♻️', package: '📦', label: '🏷️', bookmark: '🔖', flag: '🚩',
|
||||||
|
// Nature & misc
|
||||||
|
sun: '☀️', moon: '🌙', cloud: '☁️', snowflake: '❄️', umbrella: '☂️',
|
||||||
|
dog: '🐶', cat: '🐱', pizza: '🍕', coffee: '☕', beer: '🍺',
|
||||||
|
white_flag: '🏳️', checkered_flag: '🏁', construction: '🚧', sos: '🆘',
|
||||||
|
info: 'ℹ️', new: '🆕', free: '🆓', cool: '🆒', up: '🆙', soon: '🔜',
|
||||||
|
};
|
||||||
|
|
||||||
|
function replaceEmoji(text) {
|
||||||
|
return text.replace(/:([a-z0-9_+\-]+):/g, function(match, name) {
|
||||||
|
return _emojiMap[name] || match;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse markdown tables
|
||||||
|
* Supports: | Header | Header |
|
||||||
|
* |--------|--------|
|
||||||
|
* | Cell | Cell |
|
||||||
|
*/
|
||||||
|
function parseMarkdownTables(html) {
|
||||||
|
const lines = html.split('\n');
|
||||||
|
const result = [];
|
||||||
|
let inTable = false;
|
||||||
|
let tableRows = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
|
||||||
|
// Check if line is a table row (starts and ends with |, or has | in the middle)
|
||||||
|
if (line.match(/^\|.*\|$/) || line.match(/^[^|]+\|[^|]+/)) {
|
||||||
|
// Check if next line is separator (|---|---|)
|
||||||
|
const nextLine = lines[i + 1] ? lines[i + 1].trim() : '';
|
||||||
|
const isSeparator = line.match(/^\|?[\s-:|]+\|[\s-:|]+\|?$/);
|
||||||
|
|
||||||
|
if (!inTable && !isSeparator) {
|
||||||
|
// Start of table - check if this is a header row
|
||||||
|
if (nextLine.match(/^\|?[\s-:|]+\|[\s-:|]+\|?$/)) {
|
||||||
|
inTable = true;
|
||||||
|
tableRows.push({ type: 'header', content: line });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inTable) {
|
||||||
|
if (isSeparator) {
|
||||||
|
// Skip separator line
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tableRows.push({ type: 'body', content: line });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not a table row - flush any accumulated table
|
||||||
|
if (inTable && tableRows.length > 0) {
|
||||||
|
result.push(buildTable(tableRows));
|
||||||
|
tableRows = [];
|
||||||
|
inTable = false;
|
||||||
|
}
|
||||||
|
result.push(lines[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush remaining table
|
||||||
|
if (tableRows.length > 0) {
|
||||||
|
result.push(buildTable(tableRows));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build HTML table from parsed rows
|
||||||
|
*/
|
||||||
|
function buildTable(rows) {
|
||||||
|
if (rows.length === 0) return '';
|
||||||
|
|
||||||
|
let html = '<table class="markdown-table">';
|
||||||
|
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
const cells = row.content.split('|').filter(cell => cell.trim() !== '');
|
||||||
|
const tag = row.type === 'header' ? 'th' : 'td';
|
||||||
|
const wrapper = row.type === 'header' ? 'thead' : (index === 1 ? 'tbody' : '');
|
||||||
|
|
||||||
|
if (wrapper === 'thead') html += '<thead>';
|
||||||
|
if (wrapper === 'tbody') html += '<tbody>';
|
||||||
|
|
||||||
|
html += '<tr>';
|
||||||
|
cells.forEach(cell => {
|
||||||
|
html += `<${tag}>${cell.trim()}</${tag}>`;
|
||||||
|
});
|
||||||
|
html += '</tr>';
|
||||||
|
|
||||||
|
if (row.type === 'header') html += '</thead>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,3 +310,260 @@ document.addEventListener('DOMContentLoaded', renderMarkdownElements);
|
|||||||
// Expose for manual use
|
// Expose for manual use
|
||||||
window.parseMarkdown = parseMarkdown;
|
window.parseMarkdown = parseMarkdown;
|
||||||
window.renderMarkdownElements = renderMarkdownElements;
|
window.renderMarkdownElements = renderMarkdownElements;
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Rich Text Editor Toolbar Functions
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert markdown formatting around selection
|
||||||
|
*/
|
||||||
|
function insertMarkdownFormat(textareaId, prefix, suffix) {
|
||||||
|
const textarea = document.getElementById(textareaId);
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const text = textarea.value;
|
||||||
|
const selectedText = text.substring(start, end);
|
||||||
|
|
||||||
|
// Insert formatting
|
||||||
|
const newText = text.substring(0, start) + prefix + selectedText + suffix + text.substring(end);
|
||||||
|
textarea.value = newText;
|
||||||
|
|
||||||
|
// Set cursor position
|
||||||
|
if (selectedText) {
|
||||||
|
textarea.setSelectionRange(start + prefix.length, end + prefix.length);
|
||||||
|
} else {
|
||||||
|
textarea.setSelectionRange(start + prefix.length, start + prefix.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.focus();
|
||||||
|
|
||||||
|
// Trigger input event to update preview if enabled
|
||||||
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert markdown at cursor position
|
||||||
|
*/
|
||||||
|
function insertMarkdownText(textareaId, text) {
|
||||||
|
const textarea = document.getElementById(textareaId);
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const value = textarea.value;
|
||||||
|
|
||||||
|
textarea.value = value.substring(0, start) + text + value.substring(start);
|
||||||
|
textarea.setSelectionRange(start + text.length, start + text.length);
|
||||||
|
textarea.focus();
|
||||||
|
|
||||||
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toolbar button handlers
|
||||||
|
*/
|
||||||
|
function toolbarBold(textareaId) {
|
||||||
|
insertMarkdownFormat(textareaId, '**', '**');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolbarItalic(textareaId) {
|
||||||
|
insertMarkdownFormat(textareaId, '_', '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolbarCode(textareaId) {
|
||||||
|
const textarea = document.getElementById(textareaId);
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const selectedText = textarea.value.substring(textarea.selectionStart, textarea.selectionEnd);
|
||||||
|
|
||||||
|
// Use code block for multi-line, inline code for single line
|
||||||
|
if (selectedText.includes('\n')) {
|
||||||
|
insertMarkdownFormat(textareaId, '```\n', '\n```');
|
||||||
|
} else {
|
||||||
|
insertMarkdownFormat(textareaId, '`', '`');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolbarLink(textareaId) {
|
||||||
|
const textarea = document.getElementById(textareaId);
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const selectedText = textarea.value.substring(textarea.selectionStart, textarea.selectionEnd);
|
||||||
|
|
||||||
|
if (selectedText) {
|
||||||
|
// Wrap selected text as link text
|
||||||
|
insertMarkdownFormat(textareaId, '[', '](url)');
|
||||||
|
} else {
|
||||||
|
insertMarkdownText(textareaId, '[link text](url)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolbarList(textareaId) {
|
||||||
|
const textarea = document.getElementById(textareaId);
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const text = textarea.value;
|
||||||
|
|
||||||
|
// Find start of current line
|
||||||
|
let lineStart = start;
|
||||||
|
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
|
||||||
|
lineStart--;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert list marker at beginning of line
|
||||||
|
textarea.value = text.substring(0, lineStart) + '- ' + text.substring(lineStart);
|
||||||
|
textarea.setSelectionRange(start + 2, start + 2);
|
||||||
|
textarea.focus();
|
||||||
|
|
||||||
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolbarHeading(textareaId) {
|
||||||
|
const textarea = document.getElementById(textareaId);
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const text = textarea.value;
|
||||||
|
|
||||||
|
// Find start of current line
|
||||||
|
let lineStart = start;
|
||||||
|
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
|
||||||
|
lineStart--;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert heading marker at beginning of line
|
||||||
|
textarea.value = text.substring(0, lineStart) + '## ' + text.substring(lineStart);
|
||||||
|
textarea.setSelectionRange(start + 3, start + 3);
|
||||||
|
textarea.focus();
|
||||||
|
|
||||||
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolbarQuote(textareaId) {
|
||||||
|
const textarea = document.getElementById(textareaId);
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const text = textarea.value;
|
||||||
|
|
||||||
|
// Find start of current line
|
||||||
|
let lineStart = start;
|
||||||
|
while (lineStart > 0 && text[lineStart - 1] !== '\n') {
|
||||||
|
lineStart--;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert quote marker at beginning of line
|
||||||
|
textarea.value = text.substring(0, lineStart) + '> ' + text.substring(lineStart);
|
||||||
|
textarea.setSelectionRange(start + 2, start + 2);
|
||||||
|
textarea.focus();
|
||||||
|
|
||||||
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and insert toolbar HTML for a textarea
|
||||||
|
*/
|
||||||
|
function createEditorToolbar(textareaId, containerId) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const toolbar = document.createElement('div');
|
||||||
|
toolbar.className = 'editor-toolbar';
|
||||||
|
toolbar.innerHTML = `
|
||||||
|
<button type="button" data-toolbar-action="bold" data-textarea="${textareaId}" title="Bold (Ctrl+B)"><b>B</b></button>
|
||||||
|
<button type="button" data-toolbar-action="italic" data-textarea="${textareaId}" title="Italic (Ctrl+I)"><i>I</i></button>
|
||||||
|
<button type="button" data-toolbar-action="code" data-textarea="${textareaId}" title="Code"></></button>
|
||||||
|
<span class="toolbar-separator"></span>
|
||||||
|
<button type="button" data-toolbar-action="heading" data-textarea="${textareaId}" title="Heading">H</button>
|
||||||
|
<button type="button" data-toolbar-action="list" data-textarea="${textareaId}" title="List">≡</button>
|
||||||
|
<button type="button" data-toolbar-action="quote" data-textarea="${textareaId}" title="Quote">"</button>
|
||||||
|
<span class="toolbar-separator"></span>
|
||||||
|
<button type="button" data-toolbar-action="link" data-textarea="${textareaId}" title="Link">[ @ ]</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add event delegation for toolbar buttons
|
||||||
|
toolbar.addEventListener('click', function(e) {
|
||||||
|
const btn = e.target.closest('[data-toolbar-action]');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
const action = btn.dataset.toolbarAction;
|
||||||
|
const targetId = btn.dataset.textarea;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'bold': toolbarBold(targetId); break;
|
||||||
|
case 'italic': toolbarItalic(targetId); break;
|
||||||
|
case 'code': toolbarCode(targetId); break;
|
||||||
|
case 'heading': toolbarHeading(targetId); break;
|
||||||
|
case 'list': toolbarList(targetId); break;
|
||||||
|
case 'quote': toolbarQuote(targetId); break;
|
||||||
|
case 'link': toolbarLink(targetId); break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
container.insertBefore(toolbar, container.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose toolbar functions globally
|
||||||
|
window.toolbarBold = toolbarBold;
|
||||||
|
window.toolbarItalic = toolbarItalic;
|
||||||
|
window.toolbarCode = toolbarCode;
|
||||||
|
window.toolbarLink = toolbarLink;
|
||||||
|
window.toolbarList = toolbarList;
|
||||||
|
window.toolbarHeading = toolbarHeading;
|
||||||
|
window.toolbarQuote = toolbarQuote;
|
||||||
|
window.createEditorToolbar = createEditorToolbar;
|
||||||
|
window.insertMarkdownFormat = insertMarkdownFormat;
|
||||||
|
window.insertMarkdownText = insertMarkdownText;
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Auto-link URLs in plain text (non-markdown)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert plain text URLs to clickable links
|
||||||
|
* Used for non-markdown comments
|
||||||
|
*/
|
||||||
|
function autoLinkUrls(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
// Match URLs that aren't already in an href attribute
|
||||||
|
return text.replace(/(?<!["\'>])(https?:\/\/[^\s<>\[\]()]+)/g,
|
||||||
|
'<a href="$1" target="_blank" rel="noopener noreferrer" class="auto-link">$1</a>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process all non-markdown comment elements to auto-link URLs
|
||||||
|
*/
|
||||||
|
function processPlainTextComments() {
|
||||||
|
document.querySelectorAll('.comment-text:not([data-markdown])').forEach(element => {
|
||||||
|
// Only process if not already processed
|
||||||
|
if (element.dataset.linksProcessed) return;
|
||||||
|
element.innerHTML = autoLinkUrls(element.innerHTML);
|
||||||
|
element.dataset.linksProcessed = 'true';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render all [data-markdown] comment elements that haven't been rendered yet
|
||||||
|
*/
|
||||||
|
function renderMarkdownComments() {
|
||||||
|
document.querySelectorAll('.comment-text[data-markdown]:not([data-rendered])').forEach(el => {
|
||||||
|
el.classList.add('lt-markdown');
|
||||||
|
el.innerHTML = parseMarkdown(el.textContent);
|
||||||
|
el.dataset.rendered = '1';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
renderMarkdownComments();
|
||||||
|
processPlainTextComments();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.renderMarkdownComments = renderMarkdownComments;
|
||||||
|
|
||||||
|
// Expose for manual use
|
||||||
|
window.autoLinkUrls = autoLinkUrls;
|
||||||
|
window.processPlainTextComments = processPlainTextComments;
|
||||||
|
|||||||
+32
-52
@@ -8,14 +8,13 @@ let userPreferences = {};
|
|||||||
// Load preferences on page load
|
// Load preferences on page load
|
||||||
async function loadUserPreferences() {
|
async function loadUserPreferences() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/user_preferences.php');
|
const data = await lt.api.get('/api/user_preferences.php');
|
||||||
const data = await response.json();
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
userPreferences = data.preferences;
|
userPreferences = data.preferences;
|
||||||
applyPreferences();
|
applyPreferences();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading preferences:', error);
|
lt.toast.error('Error loading preferences');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +44,13 @@ function applyPreferences() {
|
|||||||
document.body.classList.add(`table-${density}`);
|
document.body.classList.add(`table-${density}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Timezone - use server default if not set
|
||||||
|
const timezone = userPreferences.timezone || window.APP_TIMEZONE || 'America/New_York';
|
||||||
|
const timezoneSelect = document.getElementById('userTimezone');
|
||||||
|
if (timezoneSelect) {
|
||||||
|
timezoneSelect.value = timezone;
|
||||||
|
}
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
const notificationsCheckbox = document.getElementById('notificationsEnabled');
|
const notificationsCheckbox = document.getElementById('notificationsEnabled');
|
||||||
if (notificationsCheckbox) {
|
if (notificationsCheckbox) {
|
||||||
@@ -66,46 +72,31 @@ function applyPreferences() {
|
|||||||
|
|
||||||
// Save preferences
|
// Save preferences
|
||||||
async function saveSettings() {
|
async function saveSettings() {
|
||||||
|
const rowsPerPage = document.getElementById('rowsPerPage');
|
||||||
|
const tableDensity = document.getElementById('tableDensity');
|
||||||
|
const userTimezone = document.getElementById('userTimezone');
|
||||||
|
const notificationsEnabled = document.getElementById('notificationsEnabled');
|
||||||
|
const soundEffects = document.getElementById('soundEffects');
|
||||||
|
const toastDuration = document.getElementById('toastDuration');
|
||||||
|
|
||||||
const prefs = {
|
const prefs = {
|
||||||
rows_per_page: document.getElementById('rowsPerPage').value,
|
rows_per_page: rowsPerPage ? rowsPerPage.value : '15',
|
||||||
default_status_filters: Array.from(document.querySelectorAll('[name="defaultFilters"]:checked'))
|
default_status_filters: Array.from(document.querySelectorAll('[name="defaultFilters"]:checked'))
|
||||||
.map(cb => cb.value).join(','),
|
.map(cb => cb.value).join(',') || 'Open,Pending,In Progress',
|
||||||
table_density: document.getElementById('tableDensity').value,
|
table_density: tableDensity ? tableDensity.value : 'normal',
|
||||||
notifications_enabled: document.getElementById('notificationsEnabled').checked ? '1' : '0',
|
timezone: userTimezone ? userTimezone.value : (window.APP_TIMEZONE || 'America/New_York'),
|
||||||
sound_effects: document.getElementById('soundEffects').checked ? '1' : '0',
|
notifications_enabled: notificationsEnabled ? (notificationsEnabled.checked ? '1' : '0') : '1',
|
||||||
toast_duration: document.getElementById('toastDuration').value
|
sound_effects: soundEffects ? (soundEffects.checked ? '1' : '0') : '1',
|
||||||
|
toast_duration: toastDuration ? toastDuration.value : '3000'
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Save each preference
|
await lt.api.post('/api/user_preferences.php', { preferences: prefs });
|
||||||
for (const [key, value] of Object.entries(prefs)) {
|
lt.toast.success('Preferences saved successfully!');
|
||||||
const response = await fetch('/api/user_preferences.php', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.CSRF_TOKEN
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ key, value })
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(`Failed to save ${key}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof toast !== 'undefined') {
|
|
||||||
toast.success('Preferences saved successfully!');
|
|
||||||
}
|
|
||||||
closeSettingsModal();
|
closeSettingsModal();
|
||||||
|
|
||||||
// Reload page to apply new preferences
|
|
||||||
setTimeout(() => window.location.reload(), 1000);
|
setTimeout(() => window.location.reload(), 1000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (typeof toast !== 'undefined') {
|
lt.toast.error('Error saving preferences');
|
||||||
toast.error('Error saving preferences');
|
|
||||||
}
|
|
||||||
console.error('Error saving preferences:', error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,24 +104,18 @@ async function saveSettings() {
|
|||||||
function openSettingsModal() {
|
function openSettingsModal() {
|
||||||
const modal = document.getElementById('settingsModal');
|
const modal = document.getElementById('settingsModal');
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.style.display = 'flex';
|
lt.modal.open('settingsModal');
|
||||||
document.body.classList.add('modal-open');
|
|
||||||
loadUserPreferences();
|
loadUserPreferences();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeSettingsModal() {
|
function closeSettingsModal() {
|
||||||
const modal = document.getElementById('settingsModal');
|
lt.modal.close('settingsModal');
|
||||||
if (modal) {
|
|
||||||
modal.style.display = 'none';
|
|
||||||
document.body.classList.remove('modal-open');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close modal when clicking on backdrop (outside the settings content)
|
// Close modal when clicking on backdrop (outside the settings content)
|
||||||
function closeOnBackdropClick(event) {
|
function closeOnBackdropClick(event) {
|
||||||
const modal = document.getElementById('settingsModal');
|
const modal = document.getElementById('settingsModal');
|
||||||
// Only close if clicking directly on the modal backdrop, not on content
|
|
||||||
if (event.target === modal) {
|
if (event.target === modal) {
|
||||||
closeSettingsModal();
|
closeSettingsModal();
|
||||||
}
|
}
|
||||||
@@ -142,15 +127,10 @@ document.addEventListener('keydown', (e) => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openSettingsModal();
|
openSettingsModal();
|
||||||
}
|
}
|
||||||
|
// ESC is handled globally by lt.keys.initDefaults()
|
||||||
// ESC to close modal
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
const modal = document.getElementById('settingsModal');
|
|
||||||
if (modal && modal.style.display === 'block') {
|
|
||||||
closeSettingsModal();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize on page load
|
// Initialize on page load
|
||||||
document.addEventListener('DOMContentLoaded', loadUserPreferences);
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
if (window.lt) loadUserPreferences();
|
||||||
|
});
|
||||||
|
|||||||
+1384
-247
File diff suppressed because it is too large
Load Diff
+14
-40
@@ -1,48 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* Terminal-style toast notification system
|
* Deprecated: use lt.toast.* directly (from web_template/base.js).
|
||||||
|
* This shim maintains backwards compatibility while callers are migrated.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function showToast(message, type = 'info', duration = 3000) {
|
// showToast() shim — used by inline view scripts
|
||||||
// Remove any existing toasts
|
function showToast(message, type = 'info', duration = 3500) {
|
||||||
const existingToast = document.querySelector('.terminal-toast');
|
switch (type) {
|
||||||
if (existingToast) {
|
case 'success': lt.toast.success(message, duration); break;
|
||||||
existingToast.remove();
|
case 'error': lt.toast.error(message, duration); break;
|
||||||
|
case 'warning': lt.toast.warning(message, duration); break;
|
||||||
|
default: lt.toast.info(message, duration); break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create toast element
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = `terminal-toast toast-${type}`;
|
|
||||||
|
|
||||||
// Icon based on type
|
|
||||||
const icons = {
|
|
||||||
success: '✓',
|
|
||||||
error: '✗',
|
|
||||||
info: 'ℹ',
|
|
||||||
warning: '⚠'
|
|
||||||
};
|
|
||||||
|
|
||||||
toast.innerHTML = `
|
|
||||||
<span class="toast-icon">[${icons[type] || 'ℹ'}]</span>
|
|
||||||
<span class="toast-message">${message}</span>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Add to document
|
|
||||||
document.body.appendChild(toast);
|
|
||||||
|
|
||||||
// Trigger animation
|
|
||||||
setTimeout(() => toast.classList.add('show'), 10);
|
|
||||||
|
|
||||||
// Auto-remove after duration
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.classList.remove('show');
|
|
||||||
setTimeout(() => toast.remove(), 300);
|
|
||||||
}, duration);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience functions
|
// window.toast.* shim — used by JS files
|
||||||
window.toast = {
|
window.toast = {
|
||||||
success: (msg, duration) => showToast(msg, 'success', duration),
|
success: (msg, dur) => lt.toast.success(msg, dur),
|
||||||
error: (msg, duration) => showToast(msg, 'error', duration),
|
error: (msg, dur) => lt.toast.error(msg, dur),
|
||||||
info: (msg, duration) => showToast(msg, 'info', duration),
|
warning: (msg, dur) => lt.toast.warning(msg, dur),
|
||||||
warning: (msg, duration) => showToast(msg, 'warning', duration)
|
info: (msg, dur) => lt.toast.info(msg, dur),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// XSS prevention helper — delegates to lt.escHtml() from web_template/base.js
|
||||||
|
function escapeHtml(text) {
|
||||||
|
return lt.escHtml(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get ticket ID from URL (handles both /ticket/123 and ?id=123 formats)
|
||||||
|
function getTicketIdFromUrl() {
|
||||||
|
const pathMatch = window.location.pathname.match(/\/ticket\/(\d+)/);
|
||||||
|
if (pathMatch) return pathMatch[1];
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
return params.get('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a terminal-style confirmation modal using the lt.modal system.
|
||||||
|
* @param {string} title - Modal title
|
||||||
|
* @param {string} message - Confirmation message
|
||||||
|
* @param {string} type - 'warning' | 'error' | 'info'
|
||||||
|
* @param {Function} onConfirm - Called when user confirms
|
||||||
|
* @param {Function|null} onCancel - Called when user cancels
|
||||||
|
*/
|
||||||
|
function showConfirmModal(title, message, type = 'warning', onConfirm, onCancel = null) {
|
||||||
|
const modalId = 'confirmModal' + Date.now();
|
||||||
|
const colors = { warning: 'var(--terminal-amber)', error: 'var(--status-closed)', info: 'var(--terminal-cyan)' };
|
||||||
|
const icons = { warning: '[ ! ]', error: '[ X ]', info: '[ i ]' };
|
||||||
|
const color = colors[type] || colors.warning;
|
||||||
|
const icon = icons[type] || icons.warning;
|
||||||
|
const safeTitle = lt.escHtml(title);
|
||||||
|
const safeMessage = lt.escHtml(message).replace(/\n/g, '<br>');
|
||||||
|
|
||||||
|
document.body.insertAdjacentHTML('beforeend', `
|
||||||
|
<div class="lt-modal-overlay" id="${modalId}" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="${modalId}_title">
|
||||||
|
<div class="lt-modal lt-modal-sm">
|
||||||
|
<div class="lt-modal-header" style="color:${color};">
|
||||||
|
<span class="lt-modal-title" id="${modalId}_title">${icon} ${safeTitle}</span>
|
||||||
|
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="lt-modal-body lt-text-center">
|
||||||
|
<p>${safeMessage}</p>
|
||||||
|
</div>
|
||||||
|
<div class="lt-modal-footer">
|
||||||
|
<button class="lt-btn lt-btn-primary" id="${modalId}_confirm">CONFIRM</button>
|
||||||
|
<button class="lt-btn lt-btn-ghost" id="${modalId}_cancel">CANCEL</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const modal = document.getElementById(modalId);
|
||||||
|
lt.modal.open(modalId);
|
||||||
|
const cleanup = (cb) => { lt.modal.close(modalId); setTimeout(() => modal.remove(), 300); if (cb) cb(); };
|
||||||
|
document.getElementById(`${modalId}_confirm`).addEventListener('click', () => cleanup(onConfirm));
|
||||||
|
document.getElementById(`${modalId}_cancel`).addEventListener('click', () => cleanup(onCancel));
|
||||||
|
modal.querySelector('[data-modal-close]').addEventListener('click', () => cleanup(onCancel));
|
||||||
|
}
|
||||||
+131
-4
@@ -1,14 +1,20 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
$envFile = __DIR__ . '/../.env';
|
$envFile = __DIR__ . '/../.env';
|
||||||
|
if (!file_exists($envFile)) {
|
||||||
|
die('Configuration error: .env file not found. Copy .env.example to .env and configure your database settings.');
|
||||||
|
}
|
||||||
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
|
$envVars = parse_ini_file($envFile, false, INI_SCANNER_TYPED);
|
||||||
|
|
||||||
// Strip quotes from values if present (parse_ini_file may include them)
|
// Strip quotes from values if present (parse_ini_file may include them)
|
||||||
if ($envVars) {
|
if ($envVars) {
|
||||||
foreach ($envVars as $key => $value) {
|
foreach ($envVars as $key => $value) {
|
||||||
if (is_string($value)) {
|
if (is_string($value)) {
|
||||||
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
if (
|
||||||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
|
(substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
||||||
|
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")
|
||||||
|
) {
|
||||||
$envVars[$key] = substr($value, 1, -1);
|
$envVars[$key] = substr($value, 1, -1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,12 +23,133 @@ if ($envVars) {
|
|||||||
|
|
||||||
// Global configuration
|
// Global configuration
|
||||||
$GLOBALS['config'] = [
|
$GLOBALS['config'] = [
|
||||||
|
// Application identity
|
||||||
|
'APP_NAME' => $envVars['APP_NAME'] ?? 'TINKER TICKETS',
|
||||||
|
'APP_SUBTITLE' => $envVars['APP_SUBTITLE'] ?? 'LotusGuild Infrastructure',
|
||||||
|
'APP_VERSION' => $envVars['APP_VERSION'] ?? '1.2',
|
||||||
|
|
||||||
|
// Asset cache-busting version — auto-computed from key asset mtimes so
|
||||||
|
// browsers always pick up changes on deploy. Override via ASSET_VERSION in .env.
|
||||||
|
'ASSET_VERSION' => (function () use ($envVars) {
|
||||||
|
if (!empty($envVars['ASSET_VERSION'])) {
|
||||||
|
return $envVars['ASSET_VERSION'];
|
||||||
|
}
|
||||||
|
$files = [
|
||||||
|
__DIR__ . '/../assets/css/base.css',
|
||||||
|
__DIR__ . '/../assets/css/dashboard.css',
|
||||||
|
__DIR__ . '/../assets/css/ticket.css',
|
||||||
|
__DIR__ . '/../assets/js/base.js',
|
||||||
|
__DIR__ . '/../assets/js/dashboard.js',
|
||||||
|
__DIR__ . '/../assets/js/ticket.js',
|
||||||
|
];
|
||||||
|
$mtime = 0;
|
||||||
|
foreach ($files as $f) {
|
||||||
|
if (file_exists($f)) {
|
||||||
|
$mtime = max($mtime, filemtime($f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $mtime ?: '20260329';
|
||||||
|
})(),
|
||||||
|
|
||||||
|
// Canonical ticket statuses — single source of truth used by views and JS
|
||||||
|
'TICKET_STATUSES' => ['Open', 'Pending', 'In Progress', 'Closed'],
|
||||||
|
|
||||||
|
// Database settings
|
||||||
'DB_HOST' => $envVars['DB_HOST'] ?? 'localhost',
|
'DB_HOST' => $envVars['DB_HOST'] ?? 'localhost',
|
||||||
'DB_USER' => $envVars['DB_USER'] ?? 'root',
|
'DB_USER' => $envVars['DB_USER'] ?? 'root',
|
||||||
'DB_PASS' => $envVars['DB_PASS'] ?? '',
|
'DB_PASS' => $envVars['DB_PASS'] ?? '',
|
||||||
'DB_NAME' => $envVars['DB_NAME'] ?? 'tinkertickets',
|
'DB_NAME' => $envVars['DB_NAME'] ?? 'tinkertickets',
|
||||||
|
|
||||||
|
// URL settings
|
||||||
'BASE_URL' => '', // Empty since we're serving from document root
|
'BASE_URL' => '', // Empty since we're serving from document root
|
||||||
'ASSETS_URL' => '/assets', // Assets URL
|
'ASSETS_URL' => '/assets', // Assets URL
|
||||||
'API_URL' => '/api' // API URL
|
'API_URL' => '/api', // API URL
|
||||||
|
|
||||||
|
// Matrix webhook (hookshot generic webhook URL)
|
||||||
|
'MATRIX_WEBHOOK_URL' => $envVars['MATRIX_WEBHOOK_URL'] ?? null,
|
||||||
|
// Comma-separated Matrix user IDs to @mention on new tickets / status changes (e.g. @jared:matrix.lotusguild.org)
|
||||||
|
'MATRIX_NOTIFY_USERS' => $envVars['MATRIX_NOTIFY_USERS'] ?? '',
|
||||||
|
// Matrix homeserver domain (e.g. matrix.lotusguild.org) — used to construct Matrix user IDs
|
||||||
|
'MATRIX_DOMAIN' => $envVars['MATRIX_DOMAIN'] ?? null,
|
||||||
|
// Internal Synapse client-API base URL (e.g. http://10.10.10.29:8008) — used to verify user existence via Admin API
|
||||||
|
'SYNAPSE_ADMIN_URL' => $envVars['SYNAPSE_ADMIN_URL'] ?? null,
|
||||||
|
// Synapse admin access token (generate with: register_new_matrix_user or admin API)
|
||||||
|
'SYNAPSE_ADMIN_TOKEN' => $envVars['SYNAPSE_ADMIN_TOKEN'] ?? null,
|
||||||
|
// Set to '1' or 'true' to send a notification when any comment is posted
|
||||||
|
'MATRIX_NOTIFY_COMMENTS' => filter_var($envVars['MATRIX_NOTIFY_COMMENTS'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||||
|
// Set to '1' or 'true' to send a notification when a ticket is assigned
|
||||||
|
'MATRIX_NOTIFY_ASSIGNMENTS' => filter_var($envVars['MATRIX_NOTIFY_ASSIGNMENTS'] ?? false, FILTER_VALIDATE_BOOLEAN),
|
||||||
|
|
||||||
|
// Domain settings for external integrations (webhooks, links, etc.)
|
||||||
|
// Set APP_DOMAIN in .env to override
|
||||||
|
'APP_DOMAIN' => $envVars['APP_DOMAIN'] ?? null,
|
||||||
|
// Allowed hosts for HTTP_HOST validation (comma-separated in .env)
|
||||||
|
'ALLOWED_HOSTS' => array_filter(array_map(
|
||||||
|
'trim',
|
||||||
|
explode(',', $envVars['ALLOWED_HOSTS'] ?? 'localhost,127.0.0.1')
|
||||||
|
)),
|
||||||
|
|
||||||
|
// Session settings
|
||||||
|
'SESSION_TIMEOUT' => 18000, // 5 hours in seconds
|
||||||
|
'SESSION_REGENERATE_INTERVAL' => 300, // Regenerate session ID every 5 minutes
|
||||||
|
|
||||||
|
// CSRF settings
|
||||||
|
'CSRF_LIFETIME' => 3600, // 1 hour in seconds
|
||||||
|
|
||||||
|
// Pagination settings
|
||||||
|
'PAGINATION_DEFAULT' => 15, // Default items per page
|
||||||
|
'PAGINATION_MAX' => 100, // Maximum items per page
|
||||||
|
|
||||||
|
// File upload settings
|
||||||
|
'MAX_UPLOAD_SIZE' => 10485760, // 10MB in bytes
|
||||||
|
'ALLOWED_FILE_TYPES' => [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/gif',
|
||||||
|
'image/webp',
|
||||||
|
'application/pdf',
|
||||||
|
'text/plain',
|
||||||
|
'text/csv',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'application/zip',
|
||||||
|
'application/x-7z-compressed',
|
||||||
|
'application/x-tar',
|
||||||
|
'application/gzip'
|
||||||
|
],
|
||||||
|
'UPLOAD_DIR' => __DIR__ . '/../uploads',
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
'RATE_LIMIT_DEFAULT' => 100, // Requests per minute for general
|
||||||
|
'RATE_LIMIT_API' => 60, // Requests per minute for API
|
||||||
|
|
||||||
|
// Audit log settings
|
||||||
|
'AUDIT_LOG_RETENTION_DAYS' => 90,
|
||||||
|
|
||||||
|
// Timezone settings
|
||||||
|
// Default: America/New_York (EST/EDT)
|
||||||
|
// Common options: America/Chicago (CST), America/Denver (MST), America/Los_Angeles (PST), UTC
|
||||||
|
'TIMEZONE' => $envVars['TIMEZONE'] ?? 'America/New_York',
|
||||||
|
'TIMEZONE_OFFSET' => null, // Will be calculated below
|
||||||
|
|
||||||
|
// LDAP / lldap settings (for user avatar lookups)
|
||||||
|
'LDAP_HOST' => $envVars['LDAP_HOST'] ?? '10.10.10.39',
|
||||||
|
'LDAP_PORT' => (int)($envVars['LDAP_PORT'] ?? 3890),
|
||||||
|
'LDAP_BIND_DN' => $envVars['LDAP_BIND_DN'] ?? 'uid=tinker-tickets,ou=people,dc=example,dc=com',
|
||||||
|
'LDAP_BIND_PW' => $envVars['LDAP_BIND_PW'] ?? '',
|
||||||
|
'LDAP_BASE_DN' => $envVars['LDAP_BASE_DN'] ?? 'dc=example,dc=com',
|
||||||
|
'LDAP_USER_BASE' => $envVars['LDAP_USER_BASE'] ?? 'ou=people,dc=example,dc=com',
|
||||||
|
'LDAP_ENABLED' => filter_var($envVars['LDAP_ENABLED'] ?? 'true', FILTER_VALIDATE_BOOLEAN),
|
||||||
|
'AVATAR_CACHE_DIR' => __DIR__ . '/../uploads/avatars',
|
||||||
|
'AVATAR_CACHE_TTL' => (int)($envVars['AVATAR_CACHE_TTL'] ?? 3600), // seconds
|
||||||
];
|
];
|
||||||
?>
|
|
||||||
|
// Set PHP default timezone
|
||||||
|
date_default_timezone_set($GLOBALS['config']['TIMEZONE']);
|
||||||
|
|
||||||
|
// Calculate UTC offset for JavaScript (in minutes, negative for west of UTC)
|
||||||
|
$now = new DateTime('now', new DateTimeZone($GLOBALS['config']['TIMEZONE']));
|
||||||
|
$GLOBALS['config']['TIMEZONE_OFFSET'] = $now->getOffset() / 60; // Convert seconds to minutes
|
||||||
|
$GLOBALS['config']['TIMEZONE_ABBREV'] = $now->format('T'); // e.g., "EST", "EDT"
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
require_once 'models/CommentModel.php';
|
require_once 'models/CommentModel.php';
|
||||||
|
|
||||||
class CommentController {
|
class CommentController
|
||||||
|
{
|
||||||
private $commentModel;
|
private $commentModel;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->commentModel = new CommentModel($conn);
|
$this->commentModel = new CommentModel($conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getCommentsByTicketId($ticketId) {
|
public function getCommentsByTicketId($ticketId)
|
||||||
|
{
|
||||||
return $this->commentModel->getCommentsByTicketId($ticketId);
|
return $this->commentModel->getCommentsByTicketId($ticketId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addComment($ticketId) {
|
public function addComment($ticketId)
|
||||||
|
{
|
||||||
// Check if this is an AJAX request
|
// Check if this is an AJAX request
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
// Get JSON data
|
// Get JSON data
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
|
|
||||||
// Validate input
|
// Validate input
|
||||||
if (empty($data['comment_text'])) {
|
if (empty($data['comment_text'])) {
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
@@ -27,10 +32,10 @@ class CommentController {
|
|||||||
]);
|
]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add comment
|
// Add comment
|
||||||
$result = $this->commentModel->addComment($ticketId, $data);
|
$result = $this->commentModel->addComment($ticketId, $data);
|
||||||
|
|
||||||
// Return JSON response
|
// Return JSON response
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode($result);
|
echo json_encode($result);
|
||||||
@@ -40,4 +45,4 @@ class CommentController {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,115 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
require_once 'models/TicketModel.php';
|
require_once 'models/TicketModel.php';
|
||||||
require_once 'models/UserPreferencesModel.php';
|
require_once 'models/UserPreferencesModel.php';
|
||||||
|
require_once 'models/StatsModel.php';
|
||||||
|
|
||||||
class DashboardController {
|
class DashboardController
|
||||||
|
{
|
||||||
private $ticketModel;
|
private $ticketModel;
|
||||||
private $prefsModel;
|
private $prefsModel;
|
||||||
|
private $statsModel;
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
/** Valid sort columns (whitelist) */
|
||||||
|
private const VALID_SORT_COLUMNS = [
|
||||||
|
'ticket_id', 'title', 'status', 'priority', 'category', 'type',
|
||||||
|
'created_at', 'updated_at', 'assigned_to', 'created_by'
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Valid statuses */
|
||||||
|
private const VALID_STATUSES = ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||||
|
|
||||||
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
$this->ticketModel = new TicketModel($conn);
|
$this->ticketModel = new TicketModel($conn);
|
||||||
$this->prefsModel = new UserPreferencesModel($conn);
|
$this->prefsModel = new UserPreferencesModel($conn);
|
||||||
|
$this->statsModel = new StatsModel($conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index() {
|
/**
|
||||||
|
* Validate and sanitize a date string
|
||||||
|
*/
|
||||||
|
private function validateDate(?string $date): ?string
|
||||||
|
{
|
||||||
|
if (empty($date)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Check if it's a valid date format (YYYY-MM-DD)
|
||||||
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) && strtotime($date) !== false) {
|
||||||
|
return $date;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate priority value (1-5)
|
||||||
|
*/
|
||||||
|
private function validatePriority($priority): ?int
|
||||||
|
{
|
||||||
|
if ($priority === null || $priority === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$val = (int)$priority;
|
||||||
|
return ($val >= 1 && $val <= 5) ? $val : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate user ID
|
||||||
|
*/
|
||||||
|
private function validateUserId($userId): ?int
|
||||||
|
{
|
||||||
|
if ($userId === null || $userId === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$val = (int)$userId;
|
||||||
|
return ($val > 0) ? $val : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
// Get user ID for preferences
|
// Get user ID for preferences
|
||||||
$userId = isset($_SESSION['user']['user_id']) ? $_SESSION['user']['user_id'] : null;
|
$userId = isset($_SESSION['user']['user_id']) ? $_SESSION['user']['user_id'] : null;
|
||||||
|
|
||||||
// Get query parameters
|
// Validate and sanitize page parameter
|
||||||
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
|
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
|
||||||
|
|
||||||
// Get rows per page from user preferences, fallback to cookie, then default
|
// Get rows per page from user preferences, fallback to cookie, then default
|
||||||
|
// Clamp to reasonable range (1-100)
|
||||||
$limit = 15;
|
$limit = 15;
|
||||||
if ($userId) {
|
if ($userId) {
|
||||||
$limit = (int)$this->prefsModel->getPreference($userId, 'rows_per_page', 15);
|
$limit = (int)$this->prefsModel->getPreference($userId, 'rows_per_page', 15);
|
||||||
} else if (isset($_COOKIE['ticketsPerPage'])) {
|
} elseif (isset($_COOKIE['ticketsPerPage'])) {
|
||||||
$limit = (int)$_COOKIE['ticketsPerPage'];
|
$limit = (int)$_COOKIE['ticketsPerPage'];
|
||||||
}
|
}
|
||||||
|
$limit = max(1, min(100, $limit));
|
||||||
|
|
||||||
$sortColumn = isset($_GET['sort']) ? $_GET['sort'] : 'ticket_id';
|
// Validate sort column against whitelist
|
||||||
$sortDirection = isset($_GET['dir']) ? $_GET['dir'] : 'desc';
|
$sortColumn = isset($_GET['sort']) && in_array($_GET['sort'], self::VALID_SORT_COLUMNS, true)
|
||||||
$category = isset($_GET['category']) ? $_GET['category'] : null;
|
? $_GET['sort']
|
||||||
$type = isset($_GET['type']) ? $_GET['type'] : null;
|
: 'ticket_id';
|
||||||
$search = isset($_GET['search']) ? trim($_GET['search']) : null;
|
|
||||||
|
// Validate sort direction
|
||||||
|
$sortDirection = isset($_GET['dir']) && strtolower($_GET['dir']) === 'asc' ? 'asc' : 'desc';
|
||||||
|
|
||||||
|
// Category and type are validated by the model (uses prepared statements)
|
||||||
|
$category = isset($_GET['category']) ? trim($_GET['category']) : null;
|
||||||
|
$type = isset($_GET['type']) ? trim($_GET['type']) : null;
|
||||||
|
|
||||||
|
// Sanitize search - limit length to prevent abuse
|
||||||
|
$search = isset($_GET['search']) ? substr(trim($_GET['search']), 0, 255) : null;
|
||||||
|
|
||||||
// Handle status filtering with user preferences
|
// Handle status filtering with user preferences
|
||||||
$status = null;
|
$status = null;
|
||||||
if (isset($_GET['status']) && !empty($_GET['status'])) {
|
if (isset($_GET['status']) && !empty($_GET['status'])) {
|
||||||
$status = $_GET['status'];
|
// Validate each status in the comma-separated list
|
||||||
} else if (!isset($_GET['show_all'])) {
|
$requestedStatuses = array_map('trim', explode(',', $_GET['status']));
|
||||||
|
$validStatuses = array_filter($requestedStatuses, function ($s) {
|
||||||
|
return in_array($s, self::VALID_STATUSES, true);
|
||||||
|
});
|
||||||
|
$status = !empty($validStatuses) ? implode(',', $validStatuses) : null;
|
||||||
|
} elseif (!isset($_GET['show_all'])) {
|
||||||
// Get default status filters from user preferences
|
// Get default status filters from user preferences
|
||||||
if ($userId) {
|
if ($userId) {
|
||||||
$status = $this->prefsModel->getPreference($userId, 'default_status_filters', 'Open,Pending,In Progress');
|
$status = $this->prefsModel->getPreference($userId, 'default_status_filters', 'Open,Pending,In Progress');
|
||||||
@@ -49,51 +120,115 @@ class DashboardController {
|
|||||||
}
|
}
|
||||||
// If $_GET['show_all'] exists or no status param with show_all, show all tickets (status = null)
|
// If $_GET['show_all'] exists or no status param with show_all, show all tickets (status = null)
|
||||||
|
|
||||||
// Build advanced search filters array
|
// Build and validate advanced search filters
|
||||||
$filters = [];
|
$filters = [];
|
||||||
if (isset($_GET['created_from'])) $filters['created_from'] = $_GET['created_from'];
|
|
||||||
if (isset($_GET['created_to'])) $filters['created_to'] = $_GET['created_to'];
|
// Validate date filters
|
||||||
if (isset($_GET['updated_from'])) $filters['updated_from'] = $_GET['updated_from'];
|
$createdFrom = $this->validateDate($_GET['created_from'] ?? null);
|
||||||
if (isset($_GET['updated_to'])) $filters['updated_to'] = $_GET['updated_to'];
|
$createdTo = $this->validateDate($_GET['created_to'] ?? null);
|
||||||
if (isset($_GET['priority_min'])) $filters['priority_min'] = $_GET['priority_min'];
|
$updatedFrom = $this->validateDate($_GET['updated_from'] ?? null);
|
||||||
if (isset($_GET['priority_max'])) $filters['priority_max'] = $_GET['priority_max'];
|
$updatedTo = $this->validateDate($_GET['updated_to'] ?? null);
|
||||||
if (isset($_GET['created_by'])) $filters['created_by'] = $_GET['created_by'];
|
$closedFrom = $this->validateDate($_GET['closed_from'] ?? null);
|
||||||
if (isset($_GET['assigned_to'])) $filters['assigned_to'] = $_GET['assigned_to'];
|
$closedTo = $this->validateDate($_GET['closed_to'] ?? null);
|
||||||
|
|
||||||
|
if ($createdFrom) {
|
||||||
|
$filters['created_from'] = $createdFrom;
|
||||||
|
}
|
||||||
|
if ($createdTo) {
|
||||||
|
$filters['created_to'] = $createdTo;
|
||||||
|
}
|
||||||
|
if ($updatedFrom) {
|
||||||
|
$filters['updated_from'] = $updatedFrom;
|
||||||
|
}
|
||||||
|
if ($updatedTo) {
|
||||||
|
$filters['updated_to'] = $updatedTo;
|
||||||
|
}
|
||||||
|
if ($closedFrom) {
|
||||||
|
$filters['closed_from'] = $closedFrom;
|
||||||
|
}
|
||||||
|
if ($closedTo) {
|
||||||
|
$filters['closed_to'] = $closedTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate priority filters; ?priority=N sets exact match (min=max=N)
|
||||||
|
$prioritySingle = $this->validatePriority($_GET['priority'] ?? null);
|
||||||
|
$priorityMin = $prioritySingle ?? $this->validatePriority($_GET['priority_min'] ?? null);
|
||||||
|
$priorityMax = $prioritySingle ?? $this->validatePriority($_GET['priority_max'] ?? null);
|
||||||
|
|
||||||
|
if ($priorityMin !== null) {
|
||||||
|
$filters['priority_min'] = $priorityMin;
|
||||||
|
}
|
||||||
|
if ($priorityMax !== null) {
|
||||||
|
$filters['priority_max'] = $priorityMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate user ID filters
|
||||||
|
$createdBy = $this->validateUserId($_GET['created_by'] ?? null);
|
||||||
|
if ($createdBy !== null) {
|
||||||
|
$filters['created_by'] = $createdBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
// assigned_to accepts a numeric user ID, 'unassigned', or the special string 'me'
|
||||||
|
$assignedToRaw = $_GET['assigned_to'] ?? null;
|
||||||
|
if ($assignedToRaw === 'unassigned') {
|
||||||
|
$filters['assigned_to'] = 'unassigned';
|
||||||
|
} elseif ($assignedToRaw === 'me' && $userId) {
|
||||||
|
$filters['assigned_to'] = (int)$userId;
|
||||||
|
} else {
|
||||||
|
$assignedTo = $this->validateUserId($assignedToRaw);
|
||||||
|
if ($assignedTo !== null) {
|
||||||
|
$filters['assigned_to'] = $assignedTo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get tickets with pagination, sorting, search, and advanced filters
|
// Get tickets with pagination, sorting, search, and advanced filters
|
||||||
$result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search, $filters);
|
$result = $this->ticketModel->getAllTickets($page, $limit, $status, $sortColumn, $sortDirection, $category, $type, $search, $filters, $GLOBALS['currentUser'] ?? []);
|
||||||
|
|
||||||
// Get categories and types for filters
|
// Get categories and types for filters (single query)
|
||||||
$categories = $this->getCategories();
|
$filterOptions = $this->getCategoriesAndTypes();
|
||||||
$types = $this->getTypes();
|
$categories = $filterOptions['categories'];
|
||||||
|
$types = $filterOptions['types'];
|
||||||
|
|
||||||
// Extract data for the view
|
// Extract data for the view
|
||||||
$tickets = $result['tickets'];
|
$tickets = $result['tickets'];
|
||||||
$totalTickets = $result['total'];
|
$totalTickets = $result['total'];
|
||||||
$totalPages = $result['pages'];
|
$totalPages = $result['pages'];
|
||||||
|
|
||||||
|
// Load dashboard statistics
|
||||||
|
$stats = $this->statsModel->getAllStats($GLOBALS['currentUser'] ?? []);
|
||||||
|
|
||||||
// Load the dashboard view
|
// Load the dashboard view
|
||||||
include 'views/DashboardView.php';
|
include 'views/DashboardView.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getCategories() {
|
/**
|
||||||
$sql = "SELECT DISTINCT category FROM tickets WHERE category IS NOT NULL ORDER BY category";
|
* Get categories and types in a single query
|
||||||
|
*
|
||||||
|
* @return array ['categories' => [...], 'types' => [...]]
|
||||||
|
*/
|
||||||
|
private function getCategoriesAndTypes(): array
|
||||||
|
{
|
||||||
|
$sql = "SELECT 'category' as field, category as value FROM tickets WHERE category IS NOT NULL
|
||||||
|
UNION
|
||||||
|
SELECT 'type' as field, type as value FROM tickets WHERE type IS NOT NULL
|
||||||
|
ORDER BY field, value";
|
||||||
|
|
||||||
$result = $this->conn->query($sql);
|
$result = $this->conn->query($sql);
|
||||||
$categories = [];
|
$categories = [];
|
||||||
while($row = $result->fetch_assoc()) {
|
|
||||||
$categories[] = $row['category'];
|
|
||||||
}
|
|
||||||
return $categories;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getTypes() {
|
|
||||||
$sql = "SELECT DISTINCT type FROM tickets WHERE type IS NOT NULL ORDER BY type";
|
|
||||||
$result = $this->conn->query($sql);
|
|
||||||
$types = [];
|
$types = [];
|
||||||
while($row = $result->fetch_assoc()) {
|
|
||||||
$types[] = $row['type'];
|
if (!$result) {
|
||||||
|
return ['categories' => $categories, 'types' => $types];
|
||||||
}
|
}
|
||||||
return $types;
|
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
if ($row['field'] === 'category' && !in_array($row['value'], $categories, true)) {
|
||||||
|
$categories[] = $row['value'];
|
||||||
|
} elseif ($row['field'] === 'type' && !in_array($row['value'], $types, true)) {
|
||||||
|
$types[] = $row['value'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['categories' => $categories, 'types' => $types];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// Use absolute paths for model includes
|
// Use absolute paths for model includes
|
||||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||||
@@ -6,46 +7,32 @@ require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
|||||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
|
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/TemplateModel.php';
|
require_once dirname(__DIR__) . '/models/TemplateModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
||||||
|
|
||||||
class TicketController {
|
class TicketController
|
||||||
|
{
|
||||||
private $ticketModel;
|
private $ticketModel;
|
||||||
private $commentModel;
|
private $commentModel;
|
||||||
private $auditLogModel;
|
private $auditLogModel;
|
||||||
private $userModel;
|
private $userModel;
|
||||||
private $workflowModel;
|
private $workflowModel;
|
||||||
private $templateModel;
|
private $templateModel;
|
||||||
private $envVars;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
|
$this->conn = $conn;
|
||||||
$this->ticketModel = new TicketModel($conn);
|
$this->ticketModel = new TicketModel($conn);
|
||||||
$this->commentModel = new CommentModel($conn);
|
$this->commentModel = new CommentModel($conn);
|
||||||
$this->auditLogModel = new AuditLogModel($conn);
|
$this->auditLogModel = new AuditLogModel($conn);
|
||||||
$this->userModel = new UserModel($conn);
|
$this->userModel = new UserModel($conn);
|
||||||
$this->workflowModel = new WorkflowModel($conn);
|
$this->workflowModel = new WorkflowModel($conn);
|
||||||
$this->templateModel = new TemplateModel($conn);
|
$this->templateModel = new TemplateModel($conn);
|
||||||
|
|
||||||
// Load environment variables for Discord webhook
|
|
||||||
$envPath = dirname(__DIR__) . '/.env';
|
|
||||||
$this->envVars = [];
|
|
||||||
if (file_exists($envPath)) {
|
|
||||||
$lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
|
||||||
foreach ($lines as $line) {
|
|
||||||
if (strpos($line, '=') !== false && strpos($line, '#') !== 0) {
|
|
||||||
list($key, $value) = explode('=', $line, 2);
|
|
||||||
$key = trim($key);
|
|
||||||
$value = trim($value);
|
|
||||||
// Remove surrounding quotes if present
|
|
||||||
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
|
||||||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
|
|
||||||
$value = substr($value, 1, -1);
|
|
||||||
}
|
|
||||||
$this->envVars[$key] = $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function view($id) {
|
public function view($id)
|
||||||
|
{
|
||||||
// Get current user
|
// Get current user
|
||||||
$currentUser = $GLOBALS['currentUser'] ?? null;
|
$currentUser = $GLOBALS['currentUser'] ?? null;
|
||||||
$userId = $currentUser['user_id'] ?? null;
|
$userId = $currentUser['user_id'] ?? null;
|
||||||
@@ -53,14 +40,16 @@ class TicketController {
|
|||||||
// Get ticket data
|
// Get ticket data
|
||||||
$ticket = $this->ticketModel->getTicketById($id);
|
$ticket = $this->ticketModel->getTicketById($id);
|
||||||
|
|
||||||
if (!$ticket) {
|
if (!$ticket || !$this->ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||||
header("HTTP/1.0 404 Not Found");
|
http_response_code(404);
|
||||||
echo "Ticket not found";
|
include dirname(__DIR__) . '/views/error_404.php';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get comments for this ticket using CommentModel
|
// Load first page of comments; show "load more" if ticket has many
|
||||||
$comments = $this->commentModel->getCommentsByTicketId($id);
|
$commentPageSize = 50;
|
||||||
|
$totalComments = $this->commentModel->getCommentCount((int)$id);
|
||||||
|
$comments = $this->commentModel->getCommentsByTicketId($id, true, $commentPageSize, 0);
|
||||||
|
|
||||||
// Get timeline for this ticket
|
// Get timeline for this ticket
|
||||||
$timeline = $this->auditLogModel->getTicketTimeline($id);
|
$timeline = $this->auditLogModel->getTicketTimeline($id);
|
||||||
@@ -71,29 +60,56 @@ class TicketController {
|
|||||||
// Get allowed status transitions for this ticket
|
// Get allowed status transitions for this ticket
|
||||||
$allowedTransitions = $this->workflowModel->getAllowedTransitions($ticket['status']);
|
$allowedTransitions = $this->workflowModel->getAllowedTransitions($ticket['status']);
|
||||||
|
|
||||||
|
// Make $conn available to view for visibility groups
|
||||||
|
$conn = $this->conn;
|
||||||
|
|
||||||
// Load the view
|
// Load the view
|
||||||
include dirname(__DIR__) . '/views/TicketView.php';
|
include dirname(__DIR__) . '/views/TicketView.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create() {
|
public function create()
|
||||||
|
{
|
||||||
// Get current user
|
// Get current user
|
||||||
$currentUser = $GLOBALS['currentUser'] ?? null;
|
$currentUser = $GLOBALS['currentUser'] ?? null;
|
||||||
$userId = $currentUser['user_id'] ?? null;
|
$userId = $currentUser['user_id'] ?? null;
|
||||||
|
|
||||||
// Check if form was submitted
|
// Check if form was submitted
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
// Validate CSRF token
|
||||||
|
require_once dirname(__DIR__) . '/middleware/CsrfMiddleware.php';
|
||||||
|
$csrfToken = $_POST['csrf_token'] ?? '';
|
||||||
|
if (!CsrfMiddleware::validateToken($csrfToken)) {
|
||||||
|
$error = "Invalid or expired security token. Please try again.";
|
||||||
|
$templates = $this->templateModel->getAllTemplates();
|
||||||
|
$allUsers = $this->userModel->getAllUsers();
|
||||||
|
$conn = $this->conn;
|
||||||
|
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle visibility groups (comes as array from checkboxes)
|
||||||
|
$visibilityGroups = null;
|
||||||
|
if (isset($_POST['visibility_groups']) && is_array($_POST['visibility_groups'])) {
|
||||||
|
$visibilityGroups = implode(',', array_map('trim', $_POST['visibility_groups']));
|
||||||
|
}
|
||||||
|
|
||||||
$ticketData = [
|
$ticketData = [
|
||||||
'title' => $_POST['title'] ?? '',
|
'title' => $_POST['title'] ?? '',
|
||||||
'description' => $_POST['description'] ?? '',
|
'description' => $_POST['description'] ?? '',
|
||||||
'priority' => $_POST['priority'] ?? '4',
|
'priority' => $_POST['priority'] ?? '4',
|
||||||
'category' => $_POST['category'] ?? 'General',
|
'category' => $_POST['category'] ?? 'General',
|
||||||
'type' => $_POST['type'] ?? 'Issue'
|
'type' => $_POST['type'] ?? 'Issue',
|
||||||
|
'visibility' => $_POST['visibility'] ?? 'public',
|
||||||
|
'visibility_groups' => $visibilityGroups,
|
||||||
|
'assigned_to' => !empty($_POST['assigned_to']) ? $_POST['assigned_to'] : null
|
||||||
];
|
];
|
||||||
|
|
||||||
// Validate input
|
// Validate input
|
||||||
if (empty($ticketData['title'])) {
|
if (empty($ticketData['title'])) {
|
||||||
$error = "Title is required";
|
$error = "Title is required";
|
||||||
$templates = $this->templateModel->getAllTemplates();
|
$templates = $this->templateModel->getAllTemplates();
|
||||||
|
$allUsers = $this->userModel->getAllUsers();
|
||||||
|
$conn = $this->conn; // Make $conn available to view
|
||||||
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -107,8 +123,19 @@ class TicketController {
|
|||||||
$GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData);
|
$GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send Discord webhook notification for new ticket
|
// Auto-link as duplicate if requested from create form
|
||||||
$this->sendDiscordWebhook($result['ticket_id'], $ticketData);
|
$linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? '');
|
||||||
|
if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) {
|
||||||
|
$depSql = "INSERT IGNORE INTO ticket_dependencies (ticket_id, depends_on_id, dependency_type, created_by)
|
||||||
|
VALUES (?, ?, 'duplicates', ?)";
|
||||||
|
$depStmt = $this->conn->prepare($depSql);
|
||||||
|
$depStmt->bind_param("ssi", $result['ticket_id'], $linkDupOfRaw, $userId);
|
||||||
|
$depStmt->execute();
|
||||||
|
$depStmt->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send Matrix notification for new ticket
|
||||||
|
NotificationHelper::sendTicketNotification($result['ticket_id'], $ticketData, 'manual');
|
||||||
|
|
||||||
// Redirect to the new ticket
|
// Redirect to the new ticket
|
||||||
header("Location: " . $GLOBALS['config']['BASE_URL'] . "/ticket/" . $result['ticket_id']);
|
header("Location: " . $GLOBALS['config']['BASE_URL'] . "/ticket/" . $result['ticket_id']);
|
||||||
@@ -116,149 +143,20 @@ class TicketController {
|
|||||||
} else {
|
} else {
|
||||||
$error = $result['error'];
|
$error = $result['error'];
|
||||||
$templates = $this->templateModel->getAllTemplates();
|
$templates = $this->templateModel->getAllTemplates();
|
||||||
|
$allUsers = $this->userModel->getAllUsers();
|
||||||
|
$conn = $this->conn; // Make $conn available to view
|
||||||
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Get all templates for the template selector
|
// Get all templates for the template selector
|
||||||
$templates = $this->templateModel->getAllTemplates();
|
$templates = $this->templateModel->getAllTemplates();
|
||||||
|
// Get all users for assignment dropdown
|
||||||
|
$allUsers = $this->userModel->getAllUsers();
|
||||||
|
$conn = $this->conn; // Make $conn available to view
|
||||||
|
|
||||||
// Display the create ticket form
|
// Display the create ticket form
|
||||||
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update($id) {
|
|
||||||
// Get current user
|
|
||||||
$currentUser = $GLOBALS['currentUser'] ?? null;
|
|
||||||
$userId = $currentUser['user_id'] ?? null;
|
|
||||||
|
|
||||||
// Check if this is an AJAX request
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
||||||
// For AJAX requests, get JSON data
|
|
||||||
$input = file_get_contents('php://input');
|
|
||||||
$data = json_decode($input, true);
|
|
||||||
|
|
||||||
// Add ticket_id to the data
|
|
||||||
$data['ticket_id'] = $id;
|
|
||||||
|
|
||||||
// Validate input data
|
|
||||||
if (empty($data['title'])) {
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
echo json_encode([
|
|
||||||
'success' => false,
|
|
||||||
'error' => 'Title cannot be empty'
|
|
||||||
]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update ticket with user tracking
|
|
||||||
$result = $this->ticketModel->updateTicket($data, $userId);
|
|
||||||
|
|
||||||
// Log ticket update to audit log
|
|
||||||
if ($result && isset($GLOBALS['auditLog']) && $userId) {
|
|
||||||
$GLOBALS['auditLog']->logTicketUpdate($userId, $id, $data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return JSON response
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
if ($result) {
|
|
||||||
echo json_encode([
|
|
||||||
'success' => true,
|
|
||||||
'status' => $data['status']
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
echo json_encode([
|
|
||||||
'success' => false,
|
|
||||||
'error' => 'Failed to update ticket'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// For direct access, redirect to view
|
|
||||||
header("Location: " . $GLOBALS['config']['BASE_URL'] . "/ticket/$id");
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function sendDiscordWebhook($ticketId, $ticketData) {
|
|
||||||
if (!isset($this->envVars['DISCORD_WEBHOOK_URL']) || empty($this->envVars['DISCORD_WEBHOOK_URL'])) {
|
|
||||||
error_log("Discord webhook URL not configured, skipping webhook for ticket creation");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$webhookUrl = $this->envVars['DISCORD_WEBHOOK_URL'];
|
|
||||||
|
|
||||||
// Create ticket URL
|
|
||||||
$ticketUrl = "http://t.lotusguild.org/ticket/$ticketId";
|
|
||||||
|
|
||||||
// Map priorities to Discord colors
|
|
||||||
$priorityColors = [
|
|
||||||
1 => 0xff4d4d, // Red
|
|
||||||
2 => 0xffa726, // Orange
|
|
||||||
3 => 0x42a5f5, // Blue
|
|
||||||
4 => 0x66bb6a, // Green
|
|
||||||
5 => 0x9e9e9e // Gray
|
|
||||||
];
|
|
||||||
|
|
||||||
$priority = (int)($ticketData['priority'] ?? 4);
|
|
||||||
$color = $priorityColors[$priority] ?? 0x3498db;
|
|
||||||
|
|
||||||
$embed = [
|
|
||||||
'title' => '🎫 New Ticket Created',
|
|
||||||
'description' => "**#{$ticketId}** - " . $ticketData['title'],
|
|
||||||
'url' => $ticketUrl,
|
|
||||||
'color' => $color,
|
|
||||||
'fields' => [
|
|
||||||
[
|
|
||||||
'name' => 'Priority',
|
|
||||||
'value' => 'P' . $priority,
|
|
||||||
'inline' => true
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'Category',
|
|
||||||
'value' => $ticketData['category'] ?? 'General',
|
|
||||||
'inline' => true
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'Type',
|
|
||||||
'value' => $ticketData['type'] ?? 'Issue',
|
|
||||||
'inline' => true
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'Status',
|
|
||||||
'value' => $ticketData['status'] ?? 'Open',
|
|
||||||
'inline' => true
|
|
||||||
]
|
|
||||||
],
|
|
||||||
'footer' => [
|
|
||||||
'text' => 'Tinker Tickets'
|
|
||||||
],
|
|
||||||
'timestamp' => date('c')
|
|
||||||
];
|
|
||||||
|
|
||||||
$payload = [
|
|
||||||
'embeds' => [$embed]
|
|
||||||
];
|
|
||||||
|
|
||||||
// Send webhook
|
|
||||||
$ch = curl_init($webhookUrl);
|
|
||||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
|
||||||
curl_setopt($ch, CURLOPT_POST, 1);
|
|
||||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
|
||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
||||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
|
||||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
|
||||||
|
|
||||||
$webhookResult = curl_exec($ch);
|
|
||||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
||||||
$curlError = curl_error($ch);
|
|
||||||
curl_close($ch);
|
|
||||||
|
|
||||||
if ($curlError) {
|
|
||||||
error_log("Discord webhook cURL error: $curlError");
|
|
||||||
} else {
|
|
||||||
error_log("Discord webhook sent for new ticket. HTTP Code: $httpCode");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
+301
-112
@@ -1,9 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
ini_set('display_errors', 1);
|
ini_set('display_errors', 0);
|
||||||
file_put_contents('debug.log', print_r($_POST, true) . "\n" . file_get_contents('php://input') . "\n", FILE_APPEND);
|
|
||||||
|
|
||||||
// Load environment variables with error check
|
// Load environment variables with error check
|
||||||
$envFile = __DIR__ . '/.env';
|
$envFile = __DIR__ . '/.env';
|
||||||
@@ -27,8 +27,10 @@ if (!$envVars) {
|
|||||||
// Strip quotes from values if present (parse_ini_file may include them)
|
// Strip quotes from values if present (parse_ini_file may include them)
|
||||||
foreach ($envVars as $key => $value) {
|
foreach ($envVars as $key => $value) {
|
||||||
if (is_string($value)) {
|
if (is_string($value)) {
|
||||||
if ((substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
if (
|
||||||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")) {
|
(substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
||||||
|
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")
|
||||||
|
) {
|
||||||
$envVars[$key] = substr($value, 1, -1);
|
$envVars[$key] = substr($value, 1, -1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,9 +52,13 @@ if ($conn->connect_error) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load application config so UrlHelper can resolve APP_DOMAIN
|
||||||
|
require_once __DIR__ . '/config/config.php';
|
||||||
|
|
||||||
// Authenticate via API key
|
// Authenticate via API key
|
||||||
require_once __DIR__ . '/middleware/ApiKeyAuth.php';
|
require_once __DIR__ . '/middleware/ApiKeyAuth.php';
|
||||||
require_once __DIR__ . '/models/AuditLogModel.php';
|
require_once __DIR__ . '/models/AuditLogModel.php';
|
||||||
|
require_once __DIR__ . '/helpers/UrlHelper.php';
|
||||||
|
|
||||||
$apiKeyAuth = new ApiKeyAuth($conn);
|
$apiKeyAuth = new ApiKeyAuth($conn);
|
||||||
|
|
||||||
@@ -64,7 +70,6 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$userId = $systemUser['user_id'];
|
$userId = $systemUser['user_id'];
|
||||||
file_put_contents('debug.log', "Authenticated as system user ID: $userId\n", FILE_APPEND);
|
|
||||||
|
|
||||||
// Create tickets table with hash column if not exists
|
// Create tickets table with hash column if not exists
|
||||||
$createTableSQL = "CREATE TABLE IF NOT EXISTS tickets (
|
$createTableSQL = "CREATE TABLE IF NOT EXISTS tickets (
|
||||||
@@ -82,95 +87,296 @@ $conn->query($createTableSQL);
|
|||||||
$rawInput = file_get_contents('php://input');
|
$rawInput = file_get_contents('php://input');
|
||||||
$data = json_decode($rawInput, true);
|
$data = json_decode($rawInput, true);
|
||||||
|
|
||||||
// Generate hash from stable components
|
// Validate required fields before any processing
|
||||||
function generateTicketHash($data) {
|
if (!is_array($data) || empty($data['title'])) {
|
||||||
// Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns)
|
// Try URL-encoded fallback
|
||||||
preg_match('/\/dev\/(sd[a-z]|nvme\d+n\d+)/', $data['title'], $deviceMatches);
|
if (empty($data['title'])) {
|
||||||
$isDriveTicket = !empty($deviceMatches);
|
parse_str($rawInput, $urlData);
|
||||||
|
if (!empty($urlData['title'])) {
|
||||||
|
$data = $urlData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!is_array($data) || empty($data['title'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['success' => false, 'error' => 'title is required']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Extract hostname from title [hostname][tags]...
|
// Generate hash from stable components
|
||||||
preg_match('/\[([\w\d-]+)\]/', $data['title'], $hostMatches);
|
function generateTicketHash($data)
|
||||||
|
{
|
||||||
|
$title = (string)($data['title'] ?? '');
|
||||||
|
|
||||||
|
// Prefer explicit serial from payload; fall back to extracting device path from title
|
||||||
|
// for backwards compatibility with older hwmonDaemon versions.
|
||||||
|
$serial = isset($data['serial']) && $data['serial'] !== null && $data['serial'] !== ''
|
||||||
|
? (string)$data['serial']
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Extract device name if present (matches /dev/sdX, /dev/nvmeXnY patterns)
|
||||||
|
preg_match('/\/dev\/(sd[a-z]+|nvme\d+n\d+)/', $title, $deviceMatches);
|
||||||
|
$isDriveTicket = !empty($deviceMatches) || $serial !== null;
|
||||||
|
|
||||||
|
// Extract first bracketed tag as hostname/source
|
||||||
|
preg_match('/^\[([^\]]+)\]/', $title, $hostMatches);
|
||||||
$hostname = $hostMatches[1] ?? '';
|
$hostname = $hostMatches[1] ?? '';
|
||||||
|
|
||||||
// Detect issue category (not specific attribute values)
|
// Detect issue category and optional sub-type
|
||||||
$issueCategory = '';
|
$issueCategory = '';
|
||||||
if (stripos($data['title'], 'SMART issues') !== false) {
|
$issueSubtype = '';
|
||||||
|
$isClusterWide = false;
|
||||||
|
|
||||||
|
if (stripos($title, 'SMART issues') !== false) {
|
||||||
$issueCategory = 'smart';
|
$issueCategory = 'smart';
|
||||||
} elseif (stripos($data['title'], 'LXC') !== false || stripos($data['title'], 'storage usage') !== false) {
|
} elseif (stripos($title, 'ZFS pool') !== false) {
|
||||||
|
$issueCategory = 'zfs';
|
||||||
|
// Extract pool name so each pool gets its own ticket
|
||||||
|
if (preg_match("/ZFS pool '([^']+)'/i", $title, $poolMatch)) {
|
||||||
|
$poolName = strtolower(preg_replace('/[^a-z0-9_]/i', '_', $poolMatch[1]));
|
||||||
|
if (stripos($title, 'state:') !== false || preg_match('/DEGRADED|FAULTED|UNAVAIL|OFFLINE/i', $title)) {
|
||||||
|
$issueSubtype = 'pool_state_' . $poolName;
|
||||||
|
} elseif (stripos($title, 'usage') !== false) {
|
||||||
|
$issueSubtype = 'pool_usage_' . $poolName;
|
||||||
|
} elseif (stripos($title, 'errors') !== false) {
|
||||||
|
$issueSubtype = 'pool_errors_' . $poolName;
|
||||||
|
} else {
|
||||||
|
$issueSubtype = 'pool_' . $poolName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif (stripos($title, 'LXC') !== false || stripos($title, 'storage usage') !== false) {
|
||||||
$issueCategory = 'storage';
|
$issueCategory = 'storage';
|
||||||
} elseif (stripos($data['title'], 'memory') !== false) {
|
// Include the LXC container ID so each container gets its own ticket
|
||||||
|
if (preg_match('/LXC\s+(\d+)/i', $title, $lxcMatch)) {
|
||||||
|
$issueSubtype = 'lxc_' . $lxcMatch[1];
|
||||||
|
}
|
||||||
|
} elseif (stripos($title, 'memory') !== false) {
|
||||||
$issueCategory = 'memory';
|
$issueCategory = 'memory';
|
||||||
} elseif (stripos($data['title'], 'cpu') !== false) {
|
} elseif (stripos($title, 'cpu') !== false) {
|
||||||
$issueCategory = 'cpu';
|
$issueCategory = 'cpu';
|
||||||
} elseif (stripos($data['title'], 'network') !== false) {
|
} elseif (stripos($title, 'network') !== false) {
|
||||||
$issueCategory = 'network';
|
$issueCategory = 'network';
|
||||||
|
} elseif (stripos($title, 'Ceph') !== false || stripos($title, '[ceph]') !== false) {
|
||||||
|
$issueCategory = 'ceph';
|
||||||
|
if (
|
||||||
|
stripos($title, '[cluster-wide]') !== false ||
|
||||||
|
stripos($title, 'HEALTH_ERR') !== false ||
|
||||||
|
stripos($title, 'HEALTH_WARN') !== false ||
|
||||||
|
stripos($title, 'cluster usage') !== false
|
||||||
|
) {
|
||||||
|
$isClusterWide = true;
|
||||||
|
}
|
||||||
|
// Normalize the specific Ceph warning type so different warnings get distinct tickets
|
||||||
|
if (stripos($title, 'slow') !== false && stripos($title, 'BlueStore') !== false) {
|
||||||
|
$issueSubtype = 'bluestore_slow';
|
||||||
|
} elseif (stripos($title, 'clock skew') !== false) {
|
||||||
|
$issueSubtype = 'clock_skew';
|
||||||
|
} elseif (stripos($title, 'cluster usage') !== false) {
|
||||||
|
$issueSubtype = 'usage';
|
||||||
|
} elseif (stripos($title, 'OSD down') !== false || preg_match('/osd\.\d+\s+is\s+DOWN/i', $title)) {
|
||||||
|
// Include the specific OSD ID so each individual OSD gets its own ticket
|
||||||
|
if (preg_match('/osd\.(\d+)/i', $title, $osdMatch)) {
|
||||||
|
$issueSubtype = 'osd_down_' . $osdMatch[1];
|
||||||
|
} else {
|
||||||
|
$issueSubtype = 'osd_down';
|
||||||
|
}
|
||||||
|
} elseif (stripos($title, 'HEALTH_ERR') !== false) {
|
||||||
|
$issueSubtype = 'health_err';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build stable components with only static data
|
// Include source type so automated tickets never collide with manual ones
|
||||||
|
$sourceType = stripos($title, '[auto]') !== false ? 'auto' : 'manual';
|
||||||
|
|
||||||
|
// Build stable components
|
||||||
$stableComponents = [
|
$stableComponents = [
|
||||||
'hostname' => $hostname,
|
'source_type' => $sourceType,
|
||||||
'issue_category' => $issueCategory, // Generic category, not specific errors
|
'issue_category' => $issueCategory,
|
||||||
'environment_tags' => array_filter(
|
'issue_subtype' => $issueSubtype,
|
||||||
explode('][', $data['title']),
|
'environment_tags' => array_values(array_filter(
|
||||||
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node'])
|
explode('][', $title),
|
||||||
)
|
fn($tag) => in_array($tag, ['production', 'development', 'staging', 'single-node', 'cluster-wide'])
|
||||||
|
)),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Only include device info for drive-specific tickets
|
// Include hostname for node-specific issues
|
||||||
if ($isDriveTicket) {
|
if (!$isClusterWide) {
|
||||||
$stableComponents['device'] = $deviceMatches[0];
|
$stableComponents['hostname'] = $hostname;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include drive identifier for drive-specific tickets.
|
||||||
|
// Use serial when available (stable across reboots/reshuffles); fall back to
|
||||||
|
// device path for tickets created before serial was added to the payload.
|
||||||
|
if ($isDriveTicket) {
|
||||||
|
$stableComponents['drive'] = $serial ?? ($deviceMatches[0] ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort arrays for consistent hashing
|
|
||||||
sort($stableComponents['environment_tags']);
|
sort($stableComponents['environment_tags']);
|
||||||
|
|
||||||
return hash('sha256', json_encode($stableComponents, JSON_UNESCAPED_SLASHES));
|
return hash('sha256', json_encode($stableComponents, JSON_UNESCAPED_SLASHES));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate tickets
|
// Shared ticket data
|
||||||
|
$title = (string)($data['title'] ?? '');
|
||||||
|
$description = (string)($data['description'] ?? '');
|
||||||
|
$status = (string)($data['status'] ?? 'Open');
|
||||||
|
$priority = $data['priority'] ?? '4';
|
||||||
|
$category = (string)($data['category'] ?? 'General');
|
||||||
|
$type = (string)($data['type'] ?? 'Issue');
|
||||||
|
|
||||||
$ticketHash = generateTicketHash($data);
|
$ticketHash = generateTicketHash($data);
|
||||||
$checkDuplicateSQL = "SELECT ticket_id FROM tickets WHERE hash = ? AND created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR)";
|
$auditLog = new AuditLogModel($conn);
|
||||||
$checkStmt = $conn->prepare($checkDuplicateSQL);
|
|
||||||
|
// Look up any existing ticket with this hash (open OR closed)
|
||||||
|
$checkStmt = $conn->prepare("SELECT ticket_id, status, title, priority FROM tickets WHERE hash = ? ORDER BY created_at DESC LIMIT 1");
|
||||||
$checkStmt->bind_param("s", $ticketHash);
|
$checkStmt->bind_param("s", $ticketHash);
|
||||||
$checkStmt->execute();
|
$checkStmt->execute();
|
||||||
$result = $checkStmt->get_result();
|
$existing = $checkStmt->get_result()->fetch_assoc();
|
||||||
|
$checkStmt->close();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$existingId = $existing['ticket_id'];
|
||||||
|
$existingStatus = $existing['status'];
|
||||||
|
$existingTitle = $existing['title'];
|
||||||
|
$existingPriority = (int)$existing['priority'];
|
||||||
|
$newPriority = (int)$priority;
|
||||||
|
|
||||||
|
if ($existingStatus !== 'Closed') {
|
||||||
|
// Ticket is still active — update title, escalate priority, and refresh
|
||||||
|
// description with latest sensor data.
|
||||||
|
$changes = [];
|
||||||
|
$updateSql = "UPDATE tickets SET updated_at = NOW(), updated_by = ?";
|
||||||
|
$bindTypes = "i";
|
||||||
|
$bindVals = [$userId];
|
||||||
|
|
||||||
|
if ($title !== $existingTitle) {
|
||||||
|
$updateSql .= ", title = ?";
|
||||||
|
$bindTypes .= "s";
|
||||||
|
$bindVals[] = $title;
|
||||||
|
$changes['title'] = ['from' => $existingTitle, 'to' => $title];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($newPriority < $existingPriority) {
|
||||||
|
$updateSql .= ", priority = ?";
|
||||||
|
$bindTypes .= "i";
|
||||||
|
$bindVals[] = $newPriority;
|
||||||
|
$changes['priority'] = ['from' => $existingPriority, 'to' => $newPriority];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always refresh the description so the ticket body shows current sensor data
|
||||||
|
if (!empty($description)) {
|
||||||
|
$updateSql .= ", description = ?";
|
||||||
|
$bindTypes .= "s";
|
||||||
|
$bindVals[] = $description;
|
||||||
|
$changes['description_refreshed'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($changes)) {
|
||||||
|
$updateSql .= " WHERE ticket_id = ?";
|
||||||
|
$bindTypes .= "s";
|
||||||
|
$bindVals[] = $existingId;
|
||||||
|
|
||||||
|
$updStmt = $conn->prepare($updateSql);
|
||||||
|
$updStmt->bind_param($bindTypes, ...$bindVals);
|
||||||
|
$updStmt->execute();
|
||||||
|
$updStmt->close();
|
||||||
|
|
||||||
|
// Only post a comment on priority escalation — title and description updates
|
||||||
|
// are silent (title changes like rising counters would spam a comment every run)
|
||||||
|
if (isset($changes['priority'])) {
|
||||||
|
$commentText = "**hwmonDaemon escalated this ticket from P{$changes['priority']['from']} to P{$changes['priority']['to']}.**\n\n```\n" . $description . "\n```";
|
||||||
|
$commentStmt = $conn->prepare(
|
||||||
|
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
|
||||||
|
);
|
||||||
|
$commentStmt->bind_param("sis", $existingId, $userId, $commentText);
|
||||||
|
$commentStmt->execute();
|
||||||
|
$commentStmt->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
$auditLog->log($userId, 'update', 'ticket', $existingId, array_merge(
|
||||||
|
array_diff_key($changes, ['description_refreshed' => true]),
|
||||||
|
['reason' => 'auto-updated by hwmonDaemon (condition worsened)']
|
||||||
|
));
|
||||||
|
|
||||||
|
// Only notify on priority escalation — title-only updates (e.g. rising
|
||||||
|
// Power_On_Hours counter) should not generate a Matrix ping every hour.
|
||||||
|
if (isset($changes['priority'])) {
|
||||||
|
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
||||||
|
NotificationHelper::sendTicketNotification($existingId, [
|
||||||
|
'title' => $title,
|
||||||
|
'priority' => $changes['priority']['to'],
|
||||||
|
'category' => $category,
|
||||||
|
'type' => $type,
|
||||||
|
'status' => $existingStatus,
|
||||||
|
], 'automated');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$conn->close();
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'ticket_id' => $existingId,
|
||||||
|
'message' => empty($changes) ? 'Duplicate — no change' : 'Existing ticket updated',
|
||||||
|
'action' => empty($changes) ? 'deduplicated' : 'updated',
|
||||||
|
'changes' => $changes,
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ticket was closed — reopen it and add a recurrence comment
|
||||||
|
$reopenStmt = $conn->prepare(
|
||||||
|
"UPDATE tickets SET status = 'Open', closed_at = NULL, updated_at = NOW(), updated_by = ? WHERE ticket_id = ?"
|
||||||
|
);
|
||||||
|
$reopenStmt->bind_param("is", $userId, $existingId);
|
||||||
|
$reopenStmt->execute();
|
||||||
|
$reopenStmt->close();
|
||||||
|
|
||||||
|
$commentText = "**Issue recurred — ticket reopened automatically.**\n\n" .
|
||||||
|
"New report received from hwmonDaemon:\n\n```\n" . $description . "\n```";
|
||||||
|
$commentStmt = $conn->prepare(
|
||||||
|
"INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled) VALUES (?, ?, 'hwmonDaemon', ?, 1)"
|
||||||
|
);
|
||||||
|
$commentStmt->bind_param("sis", $existingId, $userId, $commentText);
|
||||||
|
$commentStmt->execute();
|
||||||
|
$commentStmt->close();
|
||||||
|
|
||||||
|
$auditLog->log($userId, 'update', 'ticket', $existingId, [
|
||||||
|
'status' => ['from' => 'Closed', 'to' => 'Open'],
|
||||||
|
'reason' => 'auto-reopened by hwmonDaemon (issue recurred)',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$conn->close();
|
||||||
|
|
||||||
|
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
||||||
|
NotificationHelper::sendTicketNotification($existingId, [
|
||||||
|
'title' => $title,
|
||||||
|
'priority' => $priority,
|
||||||
|
'category' => $category,
|
||||||
|
'type' => $type,
|
||||||
|
'status' => 'Open',
|
||||||
|
], 'automated');
|
||||||
|
|
||||||
if ($result->num_rows > 0) {
|
|
||||||
$existingTicket = $result->fetch_assoc();
|
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => false,
|
'success' => true,
|
||||||
'error' => 'Duplicate ticket',
|
'ticket_id' => $existingId,
|
||||||
'existing_ticket_id' => $existingTicket['ticket_id']
|
'message' => 'Existing closed ticket reopened',
|
||||||
|
'action' => 'reopened',
|
||||||
]);
|
]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force JSON content type for all incoming requests
|
// No existing ticket — create a new one
|
||||||
header('Content-Type: application/json');
|
// Use random_int range 100000000-999999999 to avoid leading-zero IDs
|
||||||
|
try {
|
||||||
if (!$data) {
|
$ticket_id = (string)random_int(100000000, 999999999);
|
||||||
// Try parsing as URL-encoded data
|
} catch (Exception $e) {
|
||||||
parse_str($rawInput, $data);
|
$ticket_id = (string)mt_rand(100000000, 999999999);
|
||||||
}
|
}
|
||||||
|
$insertStmt = $conn->prepare(
|
||||||
// Generate ticket ID (9-digit format with leading zeros)
|
"INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
|
||||||
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||||
|
);
|
||||||
// Prepare insert query with created_by field
|
$insertStmt->bind_param(
|
||||||
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, hash, created_by)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
|
||||||
|
|
||||||
$stmt = $conn->prepare($sql);
|
|
||||||
// First, store all values in variables
|
|
||||||
$title = $data['title'];
|
|
||||||
$description = $data['description'];
|
|
||||||
$status = $data['status'] ?? 'Open';
|
|
||||||
$priority = $data['priority'] ?? '4';
|
|
||||||
$category = $data['category'] ?? 'General';
|
|
||||||
$type = $data['type'] ?? 'Issue';
|
|
||||||
|
|
||||||
// Then use the variables in bind_param
|
|
||||||
$stmt->bind_param(
|
|
||||||
"ssssssssi",
|
"ssssssssi",
|
||||||
$ticket_id,
|
$ticket_id,
|
||||||
$title,
|
$title,
|
||||||
@@ -183,61 +389,44 @@ $stmt->bind_param(
|
|||||||
$userId
|
$userId
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($stmt->execute()) {
|
try {
|
||||||
// Log ticket creation to audit log
|
$inserted = $insertStmt->execute();
|
||||||
$auditLog = new AuditLogModel($conn);
|
} catch (mysqli_sql_exception $e) {
|
||||||
|
$insertStmt->close();
|
||||||
|
if ($e->getCode() === 1062) {
|
||||||
|
// Race condition: another node inserted the same hash between our SELECT and INSERT
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Duplicate ticket']);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$insertStmt->close();
|
||||||
|
|
||||||
|
if ($inserted) {
|
||||||
$auditLog->logTicketCreate($userId, $ticket_id, [
|
$auditLog->logTicketCreate($userId, $ticket_id, [
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'priority' => $priority,
|
'priority' => $priority,
|
||||||
'category' => $category,
|
'category' => $category,
|
||||||
'type' => $type
|
'type' => $type,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$conn->close();
|
||||||
|
|
||||||
|
require_once __DIR__ . '/helpers/NotificationHelper.php';
|
||||||
|
NotificationHelper::sendTicketNotification($ticket_id, [
|
||||||
|
'title' => $title,
|
||||||
|
'priority' => $priority,
|
||||||
|
'category' => $category,
|
||||||
|
'type' => $type,
|
||||||
|
'status' => $status,
|
||||||
|
], 'automated');
|
||||||
|
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'ticket_id' => $ticket_id,
|
'ticket_id' => $ticket_id,
|
||||||
'message' => 'Ticket created successfully'
|
'message' => 'Ticket created successfully',
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
echo json_encode([
|
echo json_encode(['success' => false, 'error' => $conn->error]);
|
||||||
'success' => false,
|
|
||||||
'error' => $conn->error
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$stmt->close();
|
|
||||||
$conn->close();
|
|
||||||
|
|
||||||
// Discord webhook
|
|
||||||
$discord_webhook_url = $envVars['DISCORD_WEBHOOK_URL'];
|
|
||||||
|
|
||||||
// Map priorities to Discord colors (decimal format)
|
|
||||||
$priorityColors = [
|
|
||||||
"1" => 16736589, // --priority-1: #ff4d4d
|
|
||||||
"2" => 16753958, // --priority-2: #ffa726
|
|
||||||
"3" => 4363509, // --priority-3: #42a5f5
|
|
||||||
"4" => 6736490 // --priority-4: #66bb6a
|
|
||||||
];
|
|
||||||
|
|
||||||
$discord_data = [
|
|
||||||
"content" => "",
|
|
||||||
"embeds" => [[
|
|
||||||
"title" => "New Ticket Created: #" . $ticket_id,
|
|
||||||
"description" => $title,
|
|
||||||
"url" => "http://t.lotusguild.org/ticket/" . $ticket_id,
|
|
||||||
"color" => $priorityColors[$priority],
|
|
||||||
"fields" => [
|
|
||||||
["name" => "Priority", "value" => $priority, "inline" => true],
|
|
||||||
["name" => "Category", "value" => $category, "inline" => true],
|
|
||||||
["name" => "Type", "value" => $type, "inline" => true]
|
|
||||||
]
|
|
||||||
]]
|
|
||||||
];
|
|
||||||
|
|
||||||
$ch = curl_init($discord_webhook_url);
|
|
||||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
|
||||||
curl_setopt($ch, CURLOPT_POST, 1);
|
|
||||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($discord_data));
|
|
||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
||||||
curl_exec($ch);
|
|
||||||
curl_close($ch);
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate Limit Cleanup Cron Job
|
||||||
|
*
|
||||||
|
* Cleans up expired rate limit files from the temp directory.
|
||||||
|
* Should be run via cron every 5-10 minutes:
|
||||||
|
* */
|
||||||
|
|
||||||
|
5 * * * * / usr / bin / php / path / to / cron / cleanup_ratelimit . php
|
||||||
|
*
|
||||||
|
* This script can also be run manually for immediate cleanup .
|
||||||
|
* /
|
||||||
|
|
||||||
|
// Prevent web access
|
||||||
|
if (php_sapi_name() !== 'cli') {
|
||||||
|
http_response_code(403);
|
||||||
|
exit('CLI access only');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
|
||||||
|
$lockFile = $rateLimitDir . '/.cleanup.lock';
|
||||||
|
$maxAge = 120; // 2 minutes (2x the rate limit window)
|
||||||
|
$maxLockAge = 300; // 5 minutes - release stale locks
|
||||||
|
|
||||||
|
// Check if directory exists
|
||||||
|
if (!is_dir($rateLimitDir)) {
|
||||||
|
echo "Rate limit directory does not exist: {$rateLimitDir}\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire lock to prevent concurrent cleanups
|
||||||
|
if (file_exists($lockFile)) {
|
||||||
|
$lockAge = time() - filemtime($lockFile);
|
||||||
|
if ($lockAge < $maxLockAge) {
|
||||||
|
echo "Cleanup already in progress (lock age: {$lockAge}s)\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
// Stale lock, remove it
|
||||||
|
@unlink($lockFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create lock file
|
||||||
|
if (!@touch($lockFile)) {
|
||||||
|
echo "Could not create lock file\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$deleted = 0;
|
||||||
|
$scanned = 0;
|
||||||
|
$errors = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$iterator = new DirectoryIterator($rateLimitDir);
|
||||||
|
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if ($file->isDot() || !$file->isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip lock file and non-json files
|
||||||
|
$filename = $file->getFilename();
|
||||||
|
if ($filename === '.cleanup.lock' || !str_ends_with($filename, '.json')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scanned++;
|
||||||
|
|
||||||
|
// Check file age
|
||||||
|
$fileAge = $now - $file->getMTime();
|
||||||
|
if ($fileAge > $maxAge) {
|
||||||
|
$filepath = $file->getPathname();
|
||||||
|
if (@unlink($filepath)) {
|
||||||
|
$deleted++;
|
||||||
|
} else {
|
||||||
|
$errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo "Error during cleanup: " . $e->getMessage() . "\n";
|
||||||
|
@unlink($lockFile);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release lock
|
||||||
|
@unlink($lockFile);
|
||||||
|
|
||||||
|
// Output results
|
||||||
|
echo "Rate limit cleanup completed:\n";
|
||||||
|
echo " - Scanned: {$scanned} files\n";
|
||||||
|
echo " - Deleted: {$deleted} expired files\n";
|
||||||
|
if ($errors > 0) {
|
||||||
|
echo " - Errors: {$errors} files could not be deleted\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
exit($errors > 0 ? 1 : 0);
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recurring Tickets Cron Job
|
||||||
|
*
|
||||||
|
* Run this script via cron to automatically create tickets from recurring schedules.
|
||||||
|
* Recommended: Run every 5-15 minutes
|
||||||
|
*
|
||||||
|
* Example crontab entry:
|
||||||
|
* */
|
||||||
|
|
||||||
|
10 * * * * / usr / bin / php / path / to / cron / create_recurring_tickets . php >> / var / log / recurring_tickets . log 2 > & 1
|
||||||
|
* /
|
||||||
|
|
||||||
|
// Change to project root directory
|
||||||
|
chdir(dirname(__DIR__));
|
||||||
|
|
||||||
|
// Include required files
|
||||||
|
require_once 'config/config.php';
|
||||||
|
require_once 'models/RecurringTicketModel.php';
|
||||||
|
require_once 'models/TicketModel.php';
|
||||||
|
require_once 'models/AuditLogModel.php';
|
||||||
|
|
||||||
|
// Log function
|
||||||
|
function logMessage($message)
|
||||||
|
{
|
||||||
|
echo "[" . date('Y-m-d H:i:s') . "] " . $message . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
logMessage("Starting recurring tickets cron job");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create database connection
|
||||||
|
$conn = new mysqli(
|
||||||
|
$GLOBALS['config']['DB_HOST'],
|
||||||
|
$GLOBALS['config']['DB_USER'],
|
||||||
|
$GLOBALS['config']['DB_PASS'],
|
||||||
|
$GLOBALS['config']['DB_NAME']
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($conn->connect_error) {
|
||||||
|
throw new Exception("Database connection failed: " . $conn->connect_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize models
|
||||||
|
$recurringModel = new RecurringTicketModel($conn);
|
||||||
|
$ticketModel = new TicketModel($conn);
|
||||||
|
$auditLog = new AuditLogModel($conn);
|
||||||
|
|
||||||
|
// Get all due recurring tickets
|
||||||
|
$dueTickets = $recurringModel->getDueRecurringTickets();
|
||||||
|
logMessage("Found " . count($dueTickets) . " recurring tickets due for creation");
|
||||||
|
|
||||||
|
$created = 0;
|
||||||
|
$errors = 0;
|
||||||
|
|
||||||
|
foreach ($dueTickets as $recurring) {
|
||||||
|
logMessage("Processing recurring ticket ID: " . $recurring['recurring_id']);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prepare ticket data
|
||||||
|
$ticketData = [
|
||||||
|
'title' => processTemplate($recurring['title_template']),
|
||||||
|
'description' => processTemplate($recurring['description_template']),
|
||||||
|
'category' => $recurring['category'],
|
||||||
|
'type' => $recurring['type'],
|
||||||
|
'priority' => $recurring['priority'],
|
||||||
|
'status' => 'Open'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create the ticket
|
||||||
|
$result = $ticketModel->createTicket($ticketData, $recurring['created_by']);
|
||||||
|
|
||||||
|
if ($result['success']) {
|
||||||
|
$ticketId = $result['ticket_id'];
|
||||||
|
logMessage("Created ticket: " . $ticketId);
|
||||||
|
|
||||||
|
// Assign to user if specified
|
||||||
|
if ($recurring['assigned_to']) {
|
||||||
|
$ticketModel->assignTicket($ticketId, $recurring['assigned_to'], $recurring['created_by']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log to audit
|
||||||
|
$auditLog->log(
|
||||||
|
$recurring['created_by'],
|
||||||
|
'create',
|
||||||
|
'ticket',
|
||||||
|
$ticketId,
|
||||||
|
['source' => 'recurring', 'recurring_id' => $recurring['recurring_id']]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update the recurring ticket's next run time
|
||||||
|
$recurringModel->updateAfterRun($recurring['recurring_id']);
|
||||||
|
|
||||||
|
$created++;
|
||||||
|
} else {
|
||||||
|
logMessage("ERROR: Failed to create ticket - " . ($result['error'] ?? 'Unknown error'));
|
||||||
|
$errors++;
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
logMessage("ERROR: Exception processing recurring ticket - " . $e->getMessage());
|
||||||
|
$errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logMessage("Completed: Created $created tickets, $errors errors");
|
||||||
|
|
||||||
|
$conn->close();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
logMessage("FATAL ERROR: " . $e->getMessage());
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process template variables
|
||||||
|
*/
|
||||||
|
function processTemplate($template)
|
||||||
|
{
|
||||||
|
if (empty($template)) {
|
||||||
|
return $template;
|
||||||
|
}
|
||||||
|
|
||||||
|
$replacements = [
|
||||||
|
'{{date}}' => date('Y-m-d'),
|
||||||
|
'{{time}}' => date('H:i:s'),
|
||||||
|
'{{datetime}}' => date('Y-m-d H:i:s'),
|
||||||
|
'{{week}}' => date('W'),
|
||||||
|
'{{month}}' => date('F'),
|
||||||
|
'{{year}}' => date('Y'),
|
||||||
|
'{{day_of_week}}' => date('l'),
|
||||||
|
'{{day}}' => date('d'),
|
||||||
|
];
|
||||||
|
|
||||||
|
return str_replace(array_keys($replacements), array_values($replacements), $template);
|
||||||
|
}
|
||||||
|
|
||||||
|
logMessage("Cron job finished");
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "Deploying tinker_tickets to web server..."
|
|
||||||
|
|
||||||
# Deploy to web server
|
|
||||||
echo "Syncing to web server (10.10.10.45)..."
|
|
||||||
rsync -avz --delete --exclude='.git' --exclude='deploy.sh' --exclude='.env' ./ root@10.10.10.45:/var/www/html/tinkertickets/
|
|
||||||
|
|
||||||
# Set proper permissions on the web server
|
|
||||||
echo "Setting proper file permissions..."
|
|
||||||
ssh root@10.10.10.45 "chown -R www-data:www-data /var/www/html/tinkertickets && find /var/www/html/tinkertickets -type f -exec chmod 644 {} \; && find /var/www/html/tinkertickets -type d -exec chmod 755 {} \;"
|
|
||||||
|
|
||||||
echo "Deployment to web server complete!"
|
|
||||||
echo "Don't forget to commit and push your changes via VS Code when ready."
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API Key Generator for hwmonDaemon
|
* API Key Generator for hwmonDaemon
|
||||||
* Run this script once after migrations to generate the API key
|
* Run this script once after migrations to generate the API key
|
||||||
@@ -6,6 +7,12 @@
|
|||||||
* Usage: php generate_api_key.php
|
* Usage: php generate_api_key.php
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Prevent web access
|
||||||
|
if (php_sapi_name() !== 'cli') {
|
||||||
|
http_response_code(403);
|
||||||
|
exit('CLI access only');
|
||||||
|
}
|
||||||
|
|
||||||
require_once __DIR__ . '/config/config.php';
|
require_once __DIR__ . '/config/config.php';
|
||||||
require_once __DIR__ . '/models/ApiKeyModel.php';
|
require_once __DIR__ . '/models/ApiKeyModel.php';
|
||||||
require_once __DIR__ . '/models/UserModel.php';
|
require_once __DIR__ . '/models/UserModel.php';
|
||||||
@@ -98,4 +105,3 @@ $conn->close();
|
|||||||
|
|
||||||
echo "Done! Delete this script after use:\n";
|
echo "Done! Delete this script after use:\n";
|
||||||
echo " rm " . __FILE__ . "\n\n";
|
echo " rm " . __FILE__ . "\n\n";
|
||||||
?>
|
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple File-Based Cache Helper
|
||||||
|
*
|
||||||
|
* Provides caching for frequently accessed data that doesn't change often,
|
||||||
|
* such as workflow rules, user preferences, and configuration data.
|
||||||
|
*/
|
||||||
|
class CacheHelper
|
||||||
|
{
|
||||||
|
private static ?string $cacheDir = null;
|
||||||
|
private static array $memoryCache = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cache directory path
|
||||||
|
*
|
||||||
|
* @return string Cache directory path
|
||||||
|
*/
|
||||||
|
private static function getCacheDir(): string
|
||||||
|
{
|
||||||
|
if (self::$cacheDir === null) {
|
||||||
|
self::$cacheDir = sys_get_temp_dir() . '/tinker_tickets_cache';
|
||||||
|
if (!is_dir(self::$cacheDir)) {
|
||||||
|
mkdir(self::$cacheDir, 0755, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self::$cacheDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a cache key from components
|
||||||
|
*
|
||||||
|
* @param string $prefix Cache prefix (e.g., 'workflow', 'user_prefs')
|
||||||
|
* @param mixed $identifier Unique identifier
|
||||||
|
* @return string Cache key
|
||||||
|
*/
|
||||||
|
private static function makeKey(string $prefix, $identifier = null): string
|
||||||
|
{
|
||||||
|
$key = $prefix;
|
||||||
|
if ($identifier !== null) {
|
||||||
|
$key .= '_' . md5(serialize($identifier));
|
||||||
|
}
|
||||||
|
return preg_replace('/[^a-zA-Z0-9_]/', '_', $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached data
|
||||||
|
*
|
||||||
|
* @param string $prefix Cache prefix
|
||||||
|
* @param mixed $identifier Unique identifier
|
||||||
|
* @param int $ttl Time-to-live in seconds (default 300 = 5 minutes)
|
||||||
|
* @return mixed|null Cached data or null if not found/expired
|
||||||
|
*/
|
||||||
|
public static function get(string $prefix, $identifier = null, int $ttl = 300)
|
||||||
|
{
|
||||||
|
$key = self::makeKey($prefix, $identifier);
|
||||||
|
|
||||||
|
// Check memory cache first (fastest)
|
||||||
|
if (isset(self::$memoryCache[$key])) {
|
||||||
|
$cached = self::$memoryCache[$key];
|
||||||
|
if (time() - $cached['time'] < $ttl) {
|
||||||
|
return $cached['data'];
|
||||||
|
}
|
||||||
|
unset(self::$memoryCache[$key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file cache
|
||||||
|
$filePath = self::getCacheDir() . '/' . $key . '.json';
|
||||||
|
if (file_exists($filePath)) {
|
||||||
|
$content = @file_get_contents($filePath);
|
||||||
|
if ($content !== false) {
|
||||||
|
$cached = json_decode($content, true);
|
||||||
|
if ($cached && isset($cached['time']) && isset($cached['data'])) {
|
||||||
|
if (time() - $cached['time'] < $ttl) {
|
||||||
|
// Store in memory cache for faster subsequent access
|
||||||
|
self::$memoryCache[$key] = $cached;
|
||||||
|
return $cached['data'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Expired - delete file
|
||||||
|
@unlink($filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store data in cache
|
||||||
|
*
|
||||||
|
* @param string $prefix Cache prefix
|
||||||
|
* @param mixed $identifier Unique identifier
|
||||||
|
* @param mixed $data Data to cache
|
||||||
|
* @return bool Success
|
||||||
|
*/
|
||||||
|
public static function set(string $prefix, $identifier, $data): bool
|
||||||
|
{
|
||||||
|
$key = self::makeKey($prefix, $identifier);
|
||||||
|
$cached = [
|
||||||
|
'time' => time(),
|
||||||
|
'data' => $data
|
||||||
|
];
|
||||||
|
|
||||||
|
// Store in memory cache
|
||||||
|
self::$memoryCache[$key] = $cached;
|
||||||
|
|
||||||
|
// Store in file cache
|
||||||
|
$filePath = self::getCacheDir() . '/' . $key . '.json';
|
||||||
|
return @file_put_contents($filePath, json_encode($cached), LOCK_EX) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete cached data
|
||||||
|
*
|
||||||
|
* @param string $prefix Cache prefix
|
||||||
|
* @param mixed $identifier Unique identifier (null to delete all with prefix)
|
||||||
|
* @return bool Success
|
||||||
|
*/
|
||||||
|
public static function delete(string $prefix, $identifier = null): bool
|
||||||
|
{
|
||||||
|
if ($identifier !== null) {
|
||||||
|
$key = self::makeKey($prefix, $identifier);
|
||||||
|
unset(self::$memoryCache[$key]);
|
||||||
|
$filePath = self::getCacheDir() . '/' . $key . '.json';
|
||||||
|
return !file_exists($filePath) || @unlink($filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all files with this prefix
|
||||||
|
$pattern = self::getCacheDir() . '/' . preg_replace('/[^a-zA-Z0-9_]/', '_', $prefix) . '*.json';
|
||||||
|
$files = glob($pattern);
|
||||||
|
foreach ($files as $file) {
|
||||||
|
@unlink($file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear memory cache entries with this prefix
|
||||||
|
foreach (array_keys(self::$memoryCache) as $key) {
|
||||||
|
if (strpos($key, $prefix) === 0) {
|
||||||
|
unset(self::$memoryCache[$key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all cache
|
||||||
|
*
|
||||||
|
* @return bool Success
|
||||||
|
*/
|
||||||
|
public static function clearAll(): bool
|
||||||
|
{
|
||||||
|
self::$memoryCache = [];
|
||||||
|
|
||||||
|
$files = glob(self::getCacheDir() . '/*.json');
|
||||||
|
foreach ($files as $file) {
|
||||||
|
@unlink($file);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get data from cache or fetch it using a callback
|
||||||
|
*
|
||||||
|
* @param string $prefix Cache prefix
|
||||||
|
* @param mixed $identifier Unique identifier
|
||||||
|
* @param callable $callback Function to call if cache miss
|
||||||
|
* @param int $ttl Time-to-live in seconds
|
||||||
|
* @return mixed Cached or freshly fetched data
|
||||||
|
*/
|
||||||
|
public static function remember(string $prefix, $identifier, callable $callback, int $ttl = 300)
|
||||||
|
{
|
||||||
|
$data = self::get($prefix, $identifier, $ttl);
|
||||||
|
|
||||||
|
if ($data === null) {
|
||||||
|
$data = $callback();
|
||||||
|
if ($data !== null) {
|
||||||
|
self::set($prefix, $identifier, $data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up expired cache files (call periodically)
|
||||||
|
*
|
||||||
|
* @param int $maxAge Maximum age in seconds (default 1 hour)
|
||||||
|
*/
|
||||||
|
public static function cleanup(int $maxAge = 3600): void
|
||||||
|
{
|
||||||
|
$files = glob(self::getCacheDir() . '/*.json');
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
if ($now - filemtime($file) > $maxAge) {
|
||||||
|
@unlink($file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database Connection Factory
|
||||||
|
*
|
||||||
|
* Centralizes database connection creation and management.
|
||||||
|
* Provides a singleton connection for the request lifecycle.
|
||||||
|
*/
|
||||||
|
class Database
|
||||||
|
{
|
||||||
|
private static ?mysqli $connection = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get database connection (singleton pattern)
|
||||||
|
*
|
||||||
|
* @return mysqli Database connection
|
||||||
|
* @throws Exception If connection fails
|
||||||
|
*/
|
||||||
|
public static function getConnection(): mysqli
|
||||||
|
{
|
||||||
|
if (self::$connection === null) {
|
||||||
|
self::$connection = self::createConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if connection is still alive
|
||||||
|
if (!self::$connection->ping()) {
|
||||||
|
self::$connection = self::createConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new database connection
|
||||||
|
*
|
||||||
|
* @return mysqli Database connection
|
||||||
|
* @throws Exception If connection fails
|
||||||
|
*/
|
||||||
|
private static function createConnection(): mysqli
|
||||||
|
{
|
||||||
|
// Ensure config is loaded
|
||||||
|
if (!isset($GLOBALS['config'])) {
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
$conn = new mysqli(
|
||||||
|
$GLOBALS['config']['DB_HOST'],
|
||||||
|
$GLOBALS['config']['DB_USER'],
|
||||||
|
$GLOBALS['config']['DB_PASS'],
|
||||||
|
$GLOBALS['config']['DB_NAME']
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($conn->connect_error) {
|
||||||
|
throw new Exception("Database connection failed: " . $conn->connect_error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set charset to utf8mb4 for proper Unicode support
|
||||||
|
$conn->set_charset('utf8mb4');
|
||||||
|
|
||||||
|
return $conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the database connection
|
||||||
|
*/
|
||||||
|
public static function close(): void
|
||||||
|
{
|
||||||
|
if (self::$connection !== null) {
|
||||||
|
self::$connection->close();
|
||||||
|
self::$connection = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Begin a transaction
|
||||||
|
*
|
||||||
|
* @return bool Success
|
||||||
|
*/
|
||||||
|
public static function beginTransaction(): bool
|
||||||
|
{
|
||||||
|
return self::getConnection()->begin_transaction();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commit a transaction
|
||||||
|
*
|
||||||
|
* @return bool Success
|
||||||
|
*/
|
||||||
|
public static function commit(): bool
|
||||||
|
{
|
||||||
|
return self::getConnection()->commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rollback a transaction
|
||||||
|
*
|
||||||
|
* @return bool Success
|
||||||
|
*/
|
||||||
|
public static function rollback(): bool
|
||||||
|
{
|
||||||
|
return self::getConnection()->rollback();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a query and return results
|
||||||
|
*
|
||||||
|
* @param string $sql SQL query with placeholders
|
||||||
|
* @param string $types Parameter types (i=int, s=string, d=double, b=blob)
|
||||||
|
* @param array $params Parameters to bind
|
||||||
|
* @return mysqli_result|bool Query result
|
||||||
|
*/
|
||||||
|
public static function query(string $sql, string $types = '', array $params = [])
|
||||||
|
{
|
||||||
|
$conn = self::getConnection();
|
||||||
|
|
||||||
|
if (empty($types) || empty($params)) {
|
||||||
|
return $conn->query($sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $conn->prepare($sql);
|
||||||
|
if (!$stmt) {
|
||||||
|
throw new Exception("Query preparation failed: " . $conn->error);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->bind_param($types, ...$params);
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute an INSERT/UPDATE/DELETE and return affected rows
|
||||||
|
*
|
||||||
|
* @param string $sql SQL query with placeholders
|
||||||
|
* @param string $types Parameter types
|
||||||
|
* @param array $params Parameters to bind
|
||||||
|
* @return int Affected rows (-1 on failure)
|
||||||
|
*/
|
||||||
|
public static function execute(string $sql, string $types = '', array $params = []): int
|
||||||
|
{
|
||||||
|
$conn = self::getConnection();
|
||||||
|
|
||||||
|
$stmt = $conn->prepare($sql);
|
||||||
|
if (!$stmt) {
|
||||||
|
throw new Exception("Query preparation failed: " . $conn->error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($types) && !empty($params)) {
|
||||||
|
$stmt->bind_param($types, ...$params);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stmt->execute()) {
|
||||||
|
$affected = $stmt->affected_rows;
|
||||||
|
$stmt->close();
|
||||||
|
return $affected;
|
||||||
|
}
|
||||||
|
|
||||||
|
$error = $stmt->error;
|
||||||
|
$stmt->close();
|
||||||
|
throw new Exception("Query execution failed: " . $error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the last insert ID
|
||||||
|
*
|
||||||
|
* @return int Last insert ID
|
||||||
|
*/
|
||||||
|
public static function lastInsertId(): int
|
||||||
|
{
|
||||||
|
return self::getConnection()->insert_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// escape() removed — use prepared statements with bind_param() instead
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized Error Handler
|
||||||
|
*
|
||||||
|
* Provides consistent error handling, logging, and response formatting
|
||||||
|
* across the application.
|
||||||
|
*/
|
||||||
|
class ErrorHandler
|
||||||
|
{
|
||||||
|
private static ?string $logFile = null;
|
||||||
|
private static bool $initialized = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize error handling
|
||||||
|
*
|
||||||
|
* @param bool $displayErrors Whether to display errors (false in production)
|
||||||
|
*/
|
||||||
|
public static function init(bool $displayErrors = false): void
|
||||||
|
{
|
||||||
|
if (self::$initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set error reporting
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', $displayErrors ? '1' : '0');
|
||||||
|
ini_set('log_errors', '1');
|
||||||
|
|
||||||
|
// Set up log file
|
||||||
|
self::$logFile = sys_get_temp_dir() . '/tinker_tickets_errors.log';
|
||||||
|
ini_set('error_log', self::$logFile);
|
||||||
|
|
||||||
|
// Register handlers
|
||||||
|
set_error_handler([self::class, 'handleError']);
|
||||||
|
set_exception_handler([self::class, 'handleException']);
|
||||||
|
register_shutdown_function([self::class, 'handleShutdown']);
|
||||||
|
|
||||||
|
self::$initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle PHP errors
|
||||||
|
*
|
||||||
|
* @param int $errno Error level
|
||||||
|
* @param string $errstr Error message
|
||||||
|
* @param string $errfile File where error occurred
|
||||||
|
* @param int $errline Line number
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function handleError(int $errno, string $errstr, string $errfile, int $errline): bool
|
||||||
|
{
|
||||||
|
// Don't handle suppressed errors
|
||||||
|
if (!(error_reporting() & $errno)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$errorType = self::getErrorTypeName($errno);
|
||||||
|
$message = "$errorType: $errstr in $errfile on line $errline";
|
||||||
|
|
||||||
|
self::log($message, $errno);
|
||||||
|
|
||||||
|
// For fatal errors, throw exception
|
||||||
|
if (in_array($errno, [E_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR])) {
|
||||||
|
throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle uncaught exceptions
|
||||||
|
*
|
||||||
|
* @param Throwable $exception
|
||||||
|
*/
|
||||||
|
public static function handleException(Throwable $exception): void
|
||||||
|
{
|
||||||
|
$message = sprintf(
|
||||||
|
"Uncaught %s: %s in %s on line %d\nStack trace:\n%s",
|
||||||
|
get_class($exception),
|
||||||
|
$exception->getMessage(),
|
||||||
|
$exception->getFile(),
|
||||||
|
$exception->getLine(),
|
||||||
|
$exception->getTraceAsString()
|
||||||
|
);
|
||||||
|
|
||||||
|
self::log($message, E_ERROR);
|
||||||
|
|
||||||
|
// Send error response if headers not sent
|
||||||
|
if (!headers_sent()) {
|
||||||
|
self::sendErrorResponse(
|
||||||
|
'An unexpected error occurred',
|
||||||
|
500,
|
||||||
|
$exception
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle fatal errors on shutdown
|
||||||
|
*/
|
||||||
|
public static function handleShutdown(): void
|
||||||
|
{
|
||||||
|
$error = error_get_last();
|
||||||
|
|
||||||
|
if ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
|
||||||
|
$message = sprintf(
|
||||||
|
"Fatal Error: %s in %s on line %d",
|
||||||
|
$error['message'],
|
||||||
|
$error['file'],
|
||||||
|
$error['line']
|
||||||
|
);
|
||||||
|
|
||||||
|
self::log($message, E_ERROR);
|
||||||
|
|
||||||
|
if (!headers_sent()) {
|
||||||
|
self::sendErrorResponse('A fatal error occurred', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an error message
|
||||||
|
*
|
||||||
|
* @param string $message Error message
|
||||||
|
* @param int $level Error level
|
||||||
|
* @param array $context Additional context
|
||||||
|
*/
|
||||||
|
public static function log(string $message, int $level = E_USER_NOTICE, array $context = []): void
|
||||||
|
{
|
||||||
|
$timestamp = date('Y-m-d H:i:s');
|
||||||
|
$levelName = self::getErrorTypeName($level);
|
||||||
|
|
||||||
|
$logMessage = "[$timestamp] [$levelName] $message";
|
||||||
|
|
||||||
|
if (!empty($context)) {
|
||||||
|
$logMessage .= " | Context: " . json_encode($context);
|
||||||
|
}
|
||||||
|
|
||||||
|
error_log($logMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a JSON error response
|
||||||
|
*
|
||||||
|
* @param string $message User-facing error message
|
||||||
|
* @param int $httpCode HTTP status code
|
||||||
|
* @param Throwable|null $exception Original exception (for debug info)
|
||||||
|
*/
|
||||||
|
public static function sendErrorResponse(string $message, int $httpCode = 500, ?Throwable $exception = null): void
|
||||||
|
{
|
||||||
|
http_response_code($httpCode);
|
||||||
|
|
||||||
|
if (!headers_sent()) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = [
|
||||||
|
'success' => false,
|
||||||
|
'error' => $message
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add debug info in development (check for debug mode)
|
||||||
|
if (isset($GLOBALS['config']['DEBUG']) && $GLOBALS['config']['DEBUG'] && $exception) {
|
||||||
|
$response['debug'] = [
|
||||||
|
'type' => get_class($exception),
|
||||||
|
'message' => $exception->getMessage(),
|
||||||
|
'file' => $exception->getFile(),
|
||||||
|
'line' => $exception->getLine()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode($response);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a validation error response
|
||||||
|
*
|
||||||
|
* @param array $errors Array of validation errors
|
||||||
|
* @param string $message Overall error message
|
||||||
|
*/
|
||||||
|
public static function sendValidationError(array $errors, string $message = 'Validation failed'): void
|
||||||
|
{
|
||||||
|
http_response_code(422);
|
||||||
|
|
||||||
|
if (!headers_sent()) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $message,
|
||||||
|
'validation_errors' => $errors
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a not found error response
|
||||||
|
*
|
||||||
|
* @param string $message Error message
|
||||||
|
*/
|
||||||
|
public static function sendNotFoundError(string $message = 'Resource not found'): void
|
||||||
|
{
|
||||||
|
self::sendErrorResponse($message, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an unauthorized error response
|
||||||
|
*
|
||||||
|
* @param string $message Error message
|
||||||
|
*/
|
||||||
|
public static function sendUnauthorizedError(string $message = 'Authentication required'): void
|
||||||
|
{
|
||||||
|
self::sendErrorResponse($message, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a forbidden error response
|
||||||
|
*
|
||||||
|
* @param string $message Error message
|
||||||
|
*/
|
||||||
|
public static function sendForbiddenError(string $message = 'Access denied'): void
|
||||||
|
{
|
||||||
|
self::sendErrorResponse($message, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get error type name from error number
|
||||||
|
*
|
||||||
|
* @param int $errno Error number
|
||||||
|
* @return string Error type name
|
||||||
|
*/
|
||||||
|
private static function getErrorTypeName(int $errno): string
|
||||||
|
{
|
||||||
|
$types = [
|
||||||
|
E_ERROR => 'ERROR',
|
||||||
|
E_WARNING => 'WARNING',
|
||||||
|
E_PARSE => 'PARSE',
|
||||||
|
E_NOTICE => 'NOTICE',
|
||||||
|
E_CORE_ERROR => 'CORE_ERROR',
|
||||||
|
E_CORE_WARNING => 'CORE_WARNING',
|
||||||
|
E_COMPILE_ERROR => 'COMPILE_ERROR',
|
||||||
|
E_COMPILE_WARNING => 'COMPILE_WARNING',
|
||||||
|
E_USER_ERROR => 'USER_ERROR',
|
||||||
|
E_USER_WARNING => 'USER_WARNING',
|
||||||
|
E_USER_NOTICE => 'USER_NOTICE',
|
||||||
|
E_STRICT => 'STRICT',
|
||||||
|
E_RECOVERABLE_ERROR => 'RECOVERABLE_ERROR',
|
||||||
|
E_DEPRECATED => 'DEPRECATED',
|
||||||
|
E_USER_DEPRECATED => 'USER_DEPRECATED',
|
||||||
|
];
|
||||||
|
|
||||||
|
return $types[$errno] ?? 'UNKNOWN';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent error log entries
|
||||||
|
*
|
||||||
|
* @param int $lines Number of lines to return
|
||||||
|
* @return array Log entries
|
||||||
|
*/
|
||||||
|
public static function getRecentErrors(int $lines = 50): array
|
||||||
|
{
|
||||||
|
if (self::$logFile === null || !file_exists(self::$logFile)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = file(self::$logFile);
|
||||||
|
if ($file === false) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_slice($file, -$lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
|
||||||
|
|
||||||
|
class NotificationHelper
|
||||||
|
{
|
||||||
|
// ─── Internal: fire a webhook ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static function fire(array $payload): void
|
||||||
|
{
|
||||||
|
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
|
||||||
|
if (empty($webhookUrl)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init($webhookUrl);
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||||
|
curl_setopt($ch, CURLOPT_POST, 1);
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$curlError = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
$id = $payload['ticket_id'] ?? '?';
|
||||||
|
if ($curlError) {
|
||||||
|
error_log("Matrix webhook cURL error for ticket #{$id}: {$curlError}");
|
||||||
|
} elseif ($httpCode < 200 || $httpCode >= 300) {
|
||||||
|
error_log("Matrix webhook failed for ticket #{$id}. HTTP {$httpCode}: {$response}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function notifyUsers(): array
|
||||||
|
{
|
||||||
|
$raw = $GLOBALS['config']['MATRIX_NOTIFY_USERS'] ?? '';
|
||||||
|
return array_values(array_filter(array_map('trim', explode(',', $raw))));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public event methods ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New ticket created (manual or automated/API).
|
||||||
|
*/
|
||||||
|
public static function sendTicketNotification($ticketId, array $ticketData, string $trigger = 'manual'): void
|
||||||
|
{
|
||||||
|
preg_match('/^\[([^\]]+)\]/', $ticketData['title'] ?? '', $m);
|
||||||
|
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
|
||||||
|
|
||||||
|
self::fire([
|
||||||
|
'event' => 'ticket_created',
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'title' => $ticketData['title'] ?? 'Untitled',
|
||||||
|
'priority' => (int)($ticketData['priority'] ?? 4),
|
||||||
|
'category' => $ticketData['category'] ?? 'General',
|
||||||
|
'type' => $ticketData['type'] ?? 'Issue',
|
||||||
|
'status' => $ticketData['status'] ?? 'Open',
|
||||||
|
'source' => $source,
|
||||||
|
'url' => UrlHelper::ticketUrl($ticketId),
|
||||||
|
'trigger' => $trigger,
|
||||||
|
'notify_users' => self::notifyUsers(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ticket status changed.
|
||||||
|
*
|
||||||
|
* @param string|int $ticketId
|
||||||
|
* @param string $oldStatus
|
||||||
|
* @param string $newStatus
|
||||||
|
* @param string $ticketTitle
|
||||||
|
* @param string|null $changedByDisplay Display name of the user who changed status
|
||||||
|
*/
|
||||||
|
public static function sendStatusChangeNotification($ticketId, string $oldStatus, string $newStatus, string $ticketTitle, ?string $changedByDisplay = null): void
|
||||||
|
{
|
||||||
|
self::fire([
|
||||||
|
'event' => 'status_changed',
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'title' => $ticketTitle,
|
||||||
|
'old_status' => $oldStatus,
|
||||||
|
'new_status' => $newStatus,
|
||||||
|
'changed_by' => $changedByDisplay,
|
||||||
|
'url' => UrlHelper::ticketUrl($ticketId),
|
||||||
|
'notify_users' => self::notifyUsers(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New comment posted (non-mention; use sendMentionNotification for @mentions).
|
||||||
|
*
|
||||||
|
* @param string|int $ticketId
|
||||||
|
* @param string $ticketTitle
|
||||||
|
* @param string $commentText Plain text (first 200 chars will be sent)
|
||||||
|
* @param string|null $authorDisplay Display name of commenter
|
||||||
|
* @param bool $isInternal True if the comment is internal-only
|
||||||
|
*/
|
||||||
|
public static function sendCommentNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay = null, bool $isInternal = false): void
|
||||||
|
{
|
||||||
|
// Skip if this is an internal-only comment — only the assignee/admin need to know
|
||||||
|
$notifyUsers = self::notifyUsers();
|
||||||
|
if (empty($notifyUsers)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::fire([
|
||||||
|
'event' => 'comment_added',
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'title' => $ticketTitle,
|
||||||
|
'author' => $authorDisplay,
|
||||||
|
'preview' => mb_strimwidth($commentText, 0, 200, '…'),
|
||||||
|
'is_internal' => $isInternal,
|
||||||
|
'url' => UrlHelper::ticketUrl($ticketId),
|
||||||
|
'notify_users' => $notifyUsers,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mention detected in a comment.
|
||||||
|
*
|
||||||
|
* @param string|int $ticketId
|
||||||
|
* @param string $ticketTitle
|
||||||
|
* @param string $commentText
|
||||||
|
* @param string|null $authorDisplay
|
||||||
|
* @param array $mentionedMatrixIds Matrix user IDs derived from @usernames
|
||||||
|
*/
|
||||||
|
public static function sendMentionNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay, array $mentionedMatrixIds): void
|
||||||
|
{
|
||||||
|
if (empty($mentionedMatrixIds)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::fire([
|
||||||
|
'event' => 'mention',
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'title' => $ticketTitle,
|
||||||
|
'author' => $authorDisplay,
|
||||||
|
'preview' => mb_strimwidth($commentText, 0, 200, '…'),
|
||||||
|
'url' => UrlHelper::ticketUrl($ticketId),
|
||||||
|
'notify_users' => $mentionedMatrixIds,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify all watchers of a ticket about an update event.
|
||||||
|
*
|
||||||
|
* Fetches watchers from the DB, resolves their Matrix IDs via Synapse,
|
||||||
|
* and fires the appropriate event notification with them in notify_users.
|
||||||
|
*
|
||||||
|
* @param \mysqli $conn
|
||||||
|
* @param string|int $ticketId
|
||||||
|
* @param string $ticketTitle
|
||||||
|
* @param string $event One of: status_changed, comment_added, assigned
|
||||||
|
* @param array $extraData Merged into the payload (old_status/new_status, author, etc.)
|
||||||
|
* @param int|null $excludeUserId Don't notify the actor themselves
|
||||||
|
*/
|
||||||
|
public static function notifyWatchers(\mysqli $conn, $ticketId, string $ticketTitle, string $event, array $extraData = [], ?int $excludeUserId = null): void
|
||||||
|
{
|
||||||
|
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
|
||||||
|
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
|
||||||
|
if (!$webhookUrl || !$domain) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch watcher usernames, excluding the actor so they don't notify themselves
|
||||||
|
if ($excludeUserId !== null) {
|
||||||
|
$sql = "SELECT u.username FROM ticket_watchers tw JOIN users u ON tw.user_id = u.user_id WHERE tw.ticket_id = ? AND tw.user_id != ?";
|
||||||
|
$stmt = $conn->prepare($sql);
|
||||||
|
$stmt->bind_param("ii", $ticketId, $excludeUserId);
|
||||||
|
} else {
|
||||||
|
$sql = "SELECT u.username FROM ticket_watchers tw JOIN users u ON tw.user_id = u.user_id WHERE tw.ticket_id = ?";
|
||||||
|
$stmt = $conn->prepare($sql);
|
||||||
|
$stmt->bind_param("i", $ticketId);
|
||||||
|
}
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
$usernames = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$usernames[] = $row['username'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($usernames)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve to Matrix IDs — skip users without Synapse accounts
|
||||||
|
$matrixIds = SynapseHelper::resolveUsernames($usernames);
|
||||||
|
if (empty($matrixIds)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the global notify list duplicates and build payload
|
||||||
|
$allNotify = array_unique(array_merge($matrixIds, self::notifyUsers()));
|
||||||
|
|
||||||
|
$payload = array_merge($extraData, [
|
||||||
|
'event' => $event,
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'title' => $ticketTitle,
|
||||||
|
'url' => UrlHelper::ticketUrl($ticketId),
|
||||||
|
'notify_users' => array_values($allNotify),
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::fire($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ticket assigned (or reassigned) to a user.
|
||||||
|
*
|
||||||
|
* @param string|int $ticketId
|
||||||
|
* @param string $ticketTitle
|
||||||
|
* @param string|null $assigneeName Display name of new assignee
|
||||||
|
* @param string|null $assigneeMatrix Matrix user ID of new assignee (to DM)
|
||||||
|
* @param string|null $changedByDisplay
|
||||||
|
*/
|
||||||
|
public static function sendAssignmentNotification($ticketId, string $ticketTitle, ?string $assigneeName, ?string $assigneeMatrix, ?string $changedByDisplay = null): void
|
||||||
|
{
|
||||||
|
$notifyUsers = self::notifyUsers();
|
||||||
|
// Also notify the assignee directly if we know their Matrix ID
|
||||||
|
if ($assigneeMatrix && !in_array($assigneeMatrix, $notifyUsers, true)) {
|
||||||
|
$notifyUsers[] = $assigneeMatrix;
|
||||||
|
}
|
||||||
|
if (empty($notifyUsers)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::fire([
|
||||||
|
'event' => 'assigned',
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'title' => $ticketTitle,
|
||||||
|
'assignee' => $assigneeName,
|
||||||
|
'changed_by' => $changedByDisplay,
|
||||||
|
'url' => UrlHelper::ticketUrl($ticketId),
|
||||||
|
'notify_users' => $notifyUsers,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OutputHelper - Consistent output escaping utilities
|
||||||
|
*
|
||||||
|
* Provides secure HTML escaping functions to prevent XSS attacks.
|
||||||
|
* Use these functions when outputting user-controlled data.
|
||||||
|
*/
|
||||||
|
class OutputHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Escape string for HTML output
|
||||||
|
*
|
||||||
|
* Use for text content inside HTML elements.
|
||||||
|
* Example: <p><?= OutputHelper::h($userInput) ?></p>
|
||||||
|
*
|
||||||
|
* @param string|null $string The string to escape
|
||||||
|
* @param int $flags htmlspecialchars flags (default: ENT_QUOTES | ENT_HTML5)
|
||||||
|
* @return string Escaped string
|
||||||
|
*/
|
||||||
|
public static function h(?string $string, int $flags = ENT_QUOTES | ENT_HTML5): string
|
||||||
|
{
|
||||||
|
if ($string === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return htmlspecialchars($string, $flags, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape string for HTML attribute context
|
||||||
|
*
|
||||||
|
* Use for values inside HTML attributes.
|
||||||
|
* Example: <input value="<?= OutputHelper::attr($userInput) ?>">
|
||||||
|
*
|
||||||
|
* @param string|null $string The string to escape
|
||||||
|
* @return string Escaped string
|
||||||
|
*/
|
||||||
|
public static function attr(?string $string): string
|
||||||
|
{
|
||||||
|
if ($string === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
// More aggressive escaping for attribute context
|
||||||
|
return htmlspecialchars($string, ENT_QUOTES | ENT_HTML5 | ENT_SUBSTITUTE, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode data as JSON for JavaScript context
|
||||||
|
*
|
||||||
|
* Use when embedding data in JavaScript.
|
||||||
|
* Example: <script>const data = <?= OutputHelper::json($data) ?>;</script>
|
||||||
|
*
|
||||||
|
* @param mixed $data The data to encode
|
||||||
|
* @param int $flags json_encode flags
|
||||||
|
* @return string JSON encoded string (safe for script context)
|
||||||
|
*/
|
||||||
|
public static function json($data, int $flags = 0): string
|
||||||
|
{
|
||||||
|
// Use HEX encoding for safety in HTML context
|
||||||
|
$safeFlags = JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | $flags;
|
||||||
|
return json_encode($data, $safeFlags);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL encode a string
|
||||||
|
*
|
||||||
|
* Use for values in URL query strings.
|
||||||
|
* Example: <a href="/search?q=<?= OutputHelper::url($query) ?>">
|
||||||
|
*
|
||||||
|
* @param string|null $string The string to encode
|
||||||
|
* @return string URL encoded string
|
||||||
|
*/
|
||||||
|
public static function url(?string $string): string
|
||||||
|
{
|
||||||
|
if ($string === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return rawurlencode($string);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape for CSS context
|
||||||
|
*
|
||||||
|
* Use for values in inline CSS.
|
||||||
|
* Example: <div style="color: <?= OutputHelper::css($color) ?>;">
|
||||||
|
*
|
||||||
|
* @param string|null $string The string to escape
|
||||||
|
* @return string Escaped string (only allows safe characters)
|
||||||
|
*/
|
||||||
|
public static function css(?string $string): string
|
||||||
|
{
|
||||||
|
if ($string === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
// Only allow alphanumeric, hyphens, underscores, spaces, and common CSS values
|
||||||
|
if (!preg_match('/^[a-zA-Z0-9_\-\s#.,()%]+$/', $string)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return $string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a number safely
|
||||||
|
*
|
||||||
|
* Ensures output is always a valid number.
|
||||||
|
*
|
||||||
|
* @param mixed $number The number to format
|
||||||
|
* @param int $decimals Number of decimal places
|
||||||
|
* @return string Formatted number
|
||||||
|
*/
|
||||||
|
public static function number($number, int $decimals = 0): string
|
||||||
|
{
|
||||||
|
return number_format((float)$number, $decimals, '.', ',');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an integer safely
|
||||||
|
*
|
||||||
|
* @param mixed $value The value to format
|
||||||
|
* @return int Integer value
|
||||||
|
*/
|
||||||
|
public static function int($value): int
|
||||||
|
{
|
||||||
|
return (int)$value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate string with ellipsis
|
||||||
|
*
|
||||||
|
* @param string|null $string The string to truncate
|
||||||
|
* @param int $length Maximum length
|
||||||
|
* @param string $suffix Suffix to add if truncated
|
||||||
|
* @return string Truncated and escaped string
|
||||||
|
*/
|
||||||
|
public static function truncate(?string $string, int $length = 100, string $suffix = '...'): string
|
||||||
|
{
|
||||||
|
if ($string === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb_strlen($string, 'UTF-8') <= $length) {
|
||||||
|
return self::h($string);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::h(mb_substr($string, 0, $length, 'UTF-8')) . self::h($suffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date safely
|
||||||
|
*
|
||||||
|
* @param string|int|null $date Date string, timestamp, or null
|
||||||
|
* @param string $format PHP date format
|
||||||
|
* @return string Formatted date
|
||||||
|
*/
|
||||||
|
public static function date($date, string $format = 'Y-m-d H:i:s'): string
|
||||||
|
{
|
||||||
|
if ($date === null || $date === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_numeric($date)) {
|
||||||
|
return date($format, (int)$date);
|
||||||
|
}
|
||||||
|
|
||||||
|
$timestamp = strtotime($date);
|
||||||
|
if ($timestamp === false) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return date($format, $timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a string is safe for use as a CSS class name
|
||||||
|
*
|
||||||
|
* @param string $class The class name to validate
|
||||||
|
* @return bool True if safe
|
||||||
|
*/
|
||||||
|
public static function isValidCssClass(string $class): bool
|
||||||
|
{
|
||||||
|
return preg_match('/^[a-zA-Z_][a-zA-Z0-9_-]*$/', $class) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize CSS class name(s)
|
||||||
|
*
|
||||||
|
* @param string|null $classes Space-separated class names
|
||||||
|
* @return string Sanitized class names
|
||||||
|
*/
|
||||||
|
public static function cssClass(?string $classes): string
|
||||||
|
{
|
||||||
|
if ($classes === null || $classes === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$classList = explode(' ', $classes);
|
||||||
|
$validClasses = array_filter($classList, [self::class, 'isValidCssClass']);
|
||||||
|
|
||||||
|
return implode(' ', $validClasses);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorthand function for HTML escaping
|
||||||
|
*
|
||||||
|
* @param string|null $string The string to escape
|
||||||
|
* @return string Escaped string
|
||||||
|
*/
|
||||||
|
function h(?string $string): string
|
||||||
|
{
|
||||||
|
return OutputHelper::h($string);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ResponseHelper - Standardized JSON response formatting
|
||||||
|
*
|
||||||
|
* Provides consistent API response structure across all endpoints.
|
||||||
|
*/
|
||||||
|
class ResponseHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Send a success response
|
||||||
|
*
|
||||||
|
* @param array $data Additional data to include
|
||||||
|
* @param string $message Success message
|
||||||
|
* @param int $code HTTP status code
|
||||||
|
*/
|
||||||
|
public static function success($data = [], $message = 'Success', $code = 200)
|
||||||
|
{
|
||||||
|
http_response_code($code);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(array_merge([
|
||||||
|
'success' => true,
|
||||||
|
'message' => $message
|
||||||
|
], $data));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an error response
|
||||||
|
*
|
||||||
|
* @param string $message Error message
|
||||||
|
* @param int $code HTTP status code
|
||||||
|
* @param array $data Additional data to include
|
||||||
|
*/
|
||||||
|
public static function error($message, $code = 400, $data = [])
|
||||||
|
{
|
||||||
|
http_response_code($code);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(array_merge([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $message
|
||||||
|
], $data));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an unauthorized response (401)
|
||||||
|
*
|
||||||
|
* @param string $message Error message
|
||||||
|
*/
|
||||||
|
public static function unauthorized($message = 'Authentication required')
|
||||||
|
{
|
||||||
|
self::error($message, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a forbidden response (403)
|
||||||
|
*
|
||||||
|
* @param string $message Error message
|
||||||
|
*/
|
||||||
|
public static function forbidden($message = 'Access denied')
|
||||||
|
{
|
||||||
|
self::error($message, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a not found response (404)
|
||||||
|
*
|
||||||
|
* @param string $message Error message
|
||||||
|
*/
|
||||||
|
public static function notFound($message = 'Resource not found')
|
||||||
|
{
|
||||||
|
self::error($message, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a validation error response (422)
|
||||||
|
*
|
||||||
|
* @param array $errors Validation errors
|
||||||
|
* @param string $message Error message
|
||||||
|
*/
|
||||||
|
public static function validationError($errors, $message = 'Validation failed')
|
||||||
|
{
|
||||||
|
self::error($message, 422, ['validation_errors' => $errors]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a server error response (500)
|
||||||
|
*
|
||||||
|
* @param string $message Error message
|
||||||
|
*/
|
||||||
|
public static function serverError($message = 'Internal server error')
|
||||||
|
{
|
||||||
|
self::error($message, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a rate limit exceeded response (429)
|
||||||
|
*
|
||||||
|
* @param int $retryAfter Seconds until retry is allowed
|
||||||
|
* @param string $message Error message
|
||||||
|
*/
|
||||||
|
public static function rateLimitExceeded($retryAfter = 60, $message = 'Rate limit exceeded')
|
||||||
|
{
|
||||||
|
header('Retry-After: ' . $retryAfter);
|
||||||
|
self::error($message, 429, ['retry_after' => $retryAfter]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a created response (201)
|
||||||
|
*
|
||||||
|
* @param array $data Resource data
|
||||||
|
* @param string $message Success message
|
||||||
|
*/
|
||||||
|
public static function created($data = [], $message = 'Resource created')
|
||||||
|
{
|
||||||
|
self::success($data, $message, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a no content response (204)
|
||||||
|
*/
|
||||||
|
public static function noContent()
|
||||||
|
{
|
||||||
|
http_response_code(204);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SynapseHelper
|
||||||
|
*
|
||||||
|
* Resolves local (SSO) usernames → Matrix user IDs by querying the
|
||||||
|
* Synapse Admin REST API directly. No caching — every call is live
|
||||||
|
* so results never go stale.
|
||||||
|
*
|
||||||
|
* Required config (.env) keys:
|
||||||
|
* MATRIX_DOMAIN e.g. matrix.lotusguild.org
|
||||||
|
* SYNAPSE_ADMIN_URL e.g. http://10.10.10.29:8008 (internal client-API URL)
|
||||||
|
* SYNAPSE_ADMIN_TOKEN a Synapse admin access token
|
||||||
|
*/
|
||||||
|
class SynapseHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Resolve a local SSO username to its Matrix user ID.
|
||||||
|
*
|
||||||
|
* Uses the Synapse Admin API v2 endpoint:
|
||||||
|
* GET /_synapse/admin/v2/users/@{username}:{domain}
|
||||||
|
*
|
||||||
|
* If the account exists in Synapse the method returns the Matrix ID string.
|
||||||
|
* If the account does not exist, or if Synapse is unreachable / not configured,
|
||||||
|
* it returns null silently (notifications are best-effort).
|
||||||
|
*
|
||||||
|
* @param string $username Local username (e.g. "jared")
|
||||||
|
* @return string|null Matrix user ID (e.g. "@jared:matrix.lotusguild.org") or null
|
||||||
|
*/
|
||||||
|
public static function resolveUsername(string $username): ?string
|
||||||
|
{
|
||||||
|
$baseUrl = $GLOBALS['config']['SYNAPSE_ADMIN_URL'] ?? null;
|
||||||
|
$token = $GLOBALS['config']['SYNAPSE_ADMIN_TOKEN'] ?? null;
|
||||||
|
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
|
||||||
|
|
||||||
|
if (!$baseUrl || !$token || !$domain) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the Matrix user ID and percent-encode it once for the URL path.
|
||||||
|
// rawurlencode($username) here would double-encode any special chars when
|
||||||
|
// the full $matrixId string is encoded again below.
|
||||||
|
$matrixId = '@' . $username . ':' . $domain;
|
||||||
|
$url = rtrim($baseUrl, '/') . '/_synapse/admin/v2/users/' . rawurlencode($matrixId);
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||||
|
'Authorization: Bearer ' . $token,
|
||||||
|
'Accept: application/json',
|
||||||
|
]);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
|
||||||
|
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$curlError = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($curlError) {
|
||||||
|
error_log("SynapseHelper: cURL error resolving '{$username}': {$curlError}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($httpCode === 200) {
|
||||||
|
$data = json_decode($body, true);
|
||||||
|
// Confirm the response contains the name we expect
|
||||||
|
if (!empty($data['name'])) {
|
||||||
|
return $data['name']; // e.g. "@jared:matrix.lotusguild.org"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 404 = user not found in Synapse; other codes = error
|
||||||
|
if ($httpCode !== 404) {
|
||||||
|
error_log("SynapseHelper: unexpected HTTP {$httpCode} resolving '{$username}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve multiple usernames to Matrix IDs.
|
||||||
|
* Returns only those that were successfully confirmed in Synapse.
|
||||||
|
*
|
||||||
|
* @param string[] $usernames
|
||||||
|
* @return string[] Matrix user IDs
|
||||||
|
*/
|
||||||
|
public static function resolveUsernames(array $usernames): array
|
||||||
|
{
|
||||||
|
$ids = [];
|
||||||
|
foreach ($usernames as $username) {
|
||||||
|
$id = self::resolveUsername($username);
|
||||||
|
if ($id !== null) {
|
||||||
|
$ids[] = $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $ids;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UrlHelper - URL and domain utilities
|
||||||
|
*
|
||||||
|
* Provides secure URL generation with host validation.
|
||||||
|
*/
|
||||||
|
class UrlHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the application base URL with validated host
|
||||||
|
*
|
||||||
|
* Uses APP_DOMAIN from config if set, otherwise validates HTTP_HOST
|
||||||
|
* against ALLOWED_HOSTS whitelist.
|
||||||
|
*
|
||||||
|
* @return string Base URL (e.g., "https://example.com")
|
||||||
|
*/
|
||||||
|
public static function getBaseUrl(): string
|
||||||
|
{
|
||||||
|
$protocol = self::getProtocol();
|
||||||
|
$host = self::getValidatedHost();
|
||||||
|
|
||||||
|
return "{$protocol}://{$host}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current protocol (http or https)
|
||||||
|
*
|
||||||
|
* @return string 'https' or 'http'
|
||||||
|
*/
|
||||||
|
public static function getProtocol(): string
|
||||||
|
{
|
||||||
|
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||||
|
return 'https';
|
||||||
|
}
|
||||||
|
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
|
||||||
|
return 'https';
|
||||||
|
}
|
||||||
|
if (!empty($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443) {
|
||||||
|
return 'https';
|
||||||
|
}
|
||||||
|
return 'http';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get validated hostname
|
||||||
|
*
|
||||||
|
* Priority:
|
||||||
|
* 1. APP_DOMAIN from config (if set)
|
||||||
|
* 2. HTTP_HOST if it passes validation
|
||||||
|
* 3. First allowed host as fallback
|
||||||
|
*
|
||||||
|
* @return string Validated hostname
|
||||||
|
*/
|
||||||
|
public static function getValidatedHost(): string
|
||||||
|
{
|
||||||
|
$config = $GLOBALS['config'] ?? [];
|
||||||
|
|
||||||
|
// Use configured APP_DOMAIN if available
|
||||||
|
if (!empty($config['APP_DOMAIN'])) {
|
||||||
|
return $config['APP_DOMAIN'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get allowed hosts
|
||||||
|
$allowedHosts = $config['ALLOWED_HOSTS'] ?? ['localhost'];
|
||||||
|
|
||||||
|
// Validate HTTP_HOST against whitelist
|
||||||
|
$httpHost = $_SERVER['HTTP_HOST'] ?? '';
|
||||||
|
|
||||||
|
// Strip port if present for comparison
|
||||||
|
$hostWithoutPort = preg_replace('/:\d+$/', '', $httpHost);
|
||||||
|
|
||||||
|
if (in_array($hostWithoutPort, $allowedHosts, true)) {
|
||||||
|
return $httpHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log suspicious host header
|
||||||
|
if (!empty($httpHost) && $httpHost !== 'localhost') {
|
||||||
|
error_log("UrlHelper: Rejected HTTP_HOST '{$httpHost}' - not in allowed hosts");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return first allowed host as fallback
|
||||||
|
return $allowedHosts[0] ?? 'localhost';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a full URL for a ticket
|
||||||
|
*
|
||||||
|
* @param string $ticketId Ticket ID
|
||||||
|
* @return string Full ticket URL
|
||||||
|
*/
|
||||||
|
public static function ticketUrl(string $ticketId): string
|
||||||
|
{
|
||||||
|
return self::getBaseUrl() . '/ticket/' . urlencode($ticketId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current request is using HTTPS
|
||||||
|
*
|
||||||
|
* @return bool True if HTTPS
|
||||||
|
*/
|
||||||
|
public static function isSecure(): bool
|
||||||
|
{
|
||||||
|
return self::getProtocol() === 'https';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// Main entry point for the application
|
// Main entry point for the application
|
||||||
require_once 'config/config.php';
|
require_once 'config/config.php';
|
||||||
|
require_once 'middleware/SecurityHeadersMiddleware.php';
|
||||||
require_once 'middleware/AuthMiddleware.php';
|
require_once 'middleware/AuthMiddleware.php';
|
||||||
require_once 'models/AuditLogModel.php';
|
require_once 'models/AuditLogModel.php';
|
||||||
|
|
||||||
|
// Apply security headers early
|
||||||
|
SecurityHeadersMiddleware::apply();
|
||||||
|
|
||||||
// Parse the URL - no need to remove base path since we're at document root
|
// Parse the URL - no need to remove base path since we're at document root
|
||||||
$request = $_SERVER['REQUEST_URI'];
|
$request = $_SERVER['REQUEST_URI'];
|
||||||
|
|
||||||
@@ -32,6 +37,31 @@ if (!str_starts_with($requestPath, '/api/')) {
|
|||||||
|
|
||||||
// Initialize audit log model
|
// Initialize audit log model
|
||||||
$GLOBALS['auditLog'] = new AuditLogModel($conn);
|
$GLOBALS['auditLog'] = new AuditLogModel($conn);
|
||||||
|
|
||||||
|
// Check if user has a timezone preference and apply it
|
||||||
|
if ($currentUser && isset($currentUser['user_id'])) {
|
||||||
|
require_once 'models/UserPreferencesModel.php';
|
||||||
|
$prefsModel = new UserPreferencesModel($conn);
|
||||||
|
$userTimezone = $prefsModel->getPreference($currentUser['user_id'], 'timezone', null);
|
||||||
|
if ($userTimezone && in_array($userTimezone, DateTimeZone::listIdentifiers())) {
|
||||||
|
// Override system timezone with user preference (validated against known identifiers)
|
||||||
|
date_default_timezone_set($userTimezone);
|
||||||
|
$GLOBALS['config']['TIMEZONE'] = $userTimezone;
|
||||||
|
$now = new DateTime('now', new DateTimeZone($userTimezone));
|
||||||
|
$GLOBALS['config']['TIMEZONE_OFFSET'] = $now->getOffset() / 60;
|
||||||
|
$GLOBALS['config']['TIMEZONE_ABBREV'] = $now->format('T');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: require admin or render styled 403 and exit
|
||||||
|
function requireAdmin(?array $user): void
|
||||||
|
{
|
||||||
|
if (!$user || empty($user['is_admin'])) {
|
||||||
|
http_response_code(403);
|
||||||
|
include __DIR__ . '/views/error_403.php';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple router
|
// Simple router
|
||||||
@@ -41,41 +71,353 @@ switch (true) {
|
|||||||
$controller = new DashboardController($conn);
|
$controller = new DashboardController($conn);
|
||||||
$controller->index();
|
$controller->index();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case preg_match('/^\/ticket\/(\d+)$/', $requestPath, $matches):
|
case preg_match('/^\/ticket\/(\d+)$/', $requestPath, $matches):
|
||||||
require_once 'controllers/TicketController.php';
|
require_once 'controllers/TicketController.php';
|
||||||
$controller = new TicketController($conn);
|
$controller = new TicketController($conn);
|
||||||
$controller->view($matches[1]);
|
$controller->view($matches[1]);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case $requestPath == '/ticket/create':
|
case $requestPath == '/ticket/create':
|
||||||
require_once 'controllers/TicketController.php';
|
require_once 'controllers/TicketController.php';
|
||||||
$controller = new TicketController($conn);
|
$controller = new TicketController($conn);
|
||||||
$controller->create();
|
$controller->create();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// API Routes - these handle their own database connections
|
// API Routes - these handle their own database connections
|
||||||
case $requestPath == '/api/update_ticket.php':
|
case $requestPath == '/api/update_ticket.php':
|
||||||
require_once 'api/update_ticket.php';
|
require_once 'api/update_ticket.php';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case $requestPath == '/api/add_comment.php':
|
case $requestPath == '/api/add_comment.php':
|
||||||
require_once 'api/add_comment.php';
|
require_once 'api/add_comment.php';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/update_comment.php':
|
||||||
|
require_once 'api/update_comment.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/delete_comment.php':
|
||||||
|
require_once 'api/delete_comment.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/ticket_dependencies.php':
|
||||||
|
require_once 'api/ticket_dependencies.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/upload_attachment.php':
|
||||||
|
require_once 'api/upload_attachment.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/delete_attachment.php':
|
||||||
|
require_once 'api/delete_attachment.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/get_users.php':
|
||||||
|
require_once 'api/get_users.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/get_comments.php':
|
||||||
|
require_once 'api/get_comments.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/watch_ticket.php':
|
||||||
|
require_once 'api/watch_ticket.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/assign_ticket.php':
|
||||||
|
require_once 'api/assign_ticket.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/get_template.php':
|
||||||
|
require_once 'api/get_template.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/bulk_operation.php':
|
||||||
|
require_once 'api/bulk_operation.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/export_tickets.php':
|
||||||
|
require_once 'api/export_tickets.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/generate_api_key.php':
|
||||||
|
require_once 'api/generate_api_key.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/revoke_api_key.php':
|
||||||
|
require_once 'api/revoke_api_key.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/manage_templates.php':
|
||||||
|
require_once 'api/manage_templates.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/manage_workflows.php':
|
||||||
|
require_once 'api/manage_workflows.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/manage_recurring.php':
|
||||||
|
require_once 'api/manage_recurring.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/check_duplicates.php':
|
||||||
|
require_once 'api/check_duplicates.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/custom_fields.php':
|
||||||
|
require_once 'api/custom_fields.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/saved_filters.php':
|
||||||
|
require_once 'api/saved_filters.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/audit_log.php':
|
||||||
|
require_once 'api/audit_log.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/user_preferences.php':
|
||||||
|
require_once 'api/user_preferences.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/download_attachment.php':
|
||||||
|
require_once 'api/download_attachment.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/clone_ticket.php':
|
||||||
|
require_once 'api/clone_ticket.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/health.php':
|
||||||
|
require_once 'api/health.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/notifications.php':
|
||||||
|
require_once 'api/notifications.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/user_avatar.php':
|
||||||
|
require_once 'api/user_avatar.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Admin Routes - require admin privileges
|
||||||
|
case $requestPath == '/admin/recurring-tickets':
|
||||||
|
requireAdmin($currentUser);
|
||||||
|
require_once 'models/RecurringTicketModel.php';
|
||||||
|
$recurringModel = new RecurringTicketModel($conn);
|
||||||
|
$recurringTickets = $recurringModel->getAll(true);
|
||||||
|
include 'views/admin/RecurringTicketsView.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/admin/custom-fields':
|
||||||
|
requireAdmin($currentUser);
|
||||||
|
require_once 'models/CustomFieldModel.php';
|
||||||
|
$fieldModel = new CustomFieldModel($conn);
|
||||||
|
$customFields = $fieldModel->getAllDefinitions(null, false);
|
||||||
|
include 'views/admin/CustomFieldsView.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/admin/workflow':
|
||||||
|
requireAdmin($currentUser);
|
||||||
|
$result = $conn->query("SELECT * FROM status_transitions ORDER BY from_status, to_status");
|
||||||
|
$workflows = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$workflows[] = $row;
|
||||||
|
}
|
||||||
|
include 'views/admin/WorkflowDesignerView.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/admin/templates':
|
||||||
|
requireAdmin($currentUser);
|
||||||
|
$result = $conn->query("SELECT * FROM ticket_templates ORDER BY template_name");
|
||||||
|
$templates = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$templates[] = $row;
|
||||||
|
}
|
||||||
|
include 'views/admin/TemplatesView.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/admin/audit-log':
|
||||||
|
requireAdmin($currentUser);
|
||||||
|
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
|
||||||
|
$perPage = 50;
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
|
||||||
|
$filters = [];
|
||||||
|
$whereConditions = [];
|
||||||
|
$params = [];
|
||||||
|
$types = '';
|
||||||
|
|
||||||
|
$allowedActionTypes = ['create','update','delete','comment','assign','status_change','login','security',
|
||||||
|
'ticket_create','ticket_update','ticket_delete','attachment_delete','attachment_upload'];
|
||||||
|
if (!empty($_GET['action_type']) && in_array($_GET['action_type'], $allowedActionTypes, true)) {
|
||||||
|
$whereConditions[] = "al.action_type = ?";
|
||||||
|
$params[] = $_GET['action_type'];
|
||||||
|
$types .= 's';
|
||||||
|
$filters['action_type'] = $_GET['action_type'];
|
||||||
|
}
|
||||||
|
if (!empty($_GET['user_id'])) {
|
||||||
|
$whereConditions[] = "al.user_id = ?";
|
||||||
|
$params[] = (int)$_GET['user_id'];
|
||||||
|
$types .= 'i';
|
||||||
|
$filters['user_id'] = (int)$_GET['user_id'];
|
||||||
|
}
|
||||||
|
if (!empty($_GET['date_from']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $_GET['date_from'])) {
|
||||||
|
$whereConditions[] = "DATE(al.created_at) >= ?";
|
||||||
|
$params[] = $_GET['date_from'];
|
||||||
|
$types .= 's';
|
||||||
|
$filters['date_from'] = $_GET['date_from'];
|
||||||
|
}
|
||||||
|
if (!empty($_GET['date_to']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', $_GET['date_to'])) {
|
||||||
|
$whereConditions[] = "DATE(al.created_at) <= ?";
|
||||||
|
$params[] = $_GET['date_to'];
|
||||||
|
$types .= 's';
|
||||||
|
$filters['date_to'] = $_GET['date_to'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$where = !empty($whereConditions) ? 'WHERE ' . implode(' AND ', $whereConditions) : '';
|
||||||
|
|
||||||
|
$countSql = "SELECT COUNT(*) as total FROM audit_log al $where";
|
||||||
|
if (!empty($params)) {
|
||||||
|
$stmt = $conn->prepare($countSql);
|
||||||
|
$stmt->bind_param($types, ...$params);
|
||||||
|
$stmt->execute();
|
||||||
|
$countResult = $stmt->get_result();
|
||||||
|
} else {
|
||||||
|
$countResult = $conn->query($countSql);
|
||||||
|
}
|
||||||
|
$totalLogs = $countResult->fetch_assoc()['total'];
|
||||||
|
$totalPages = ceil($totalLogs / $perPage);
|
||||||
|
|
||||||
|
$sql = "SELECT al.*, u.display_name, u.username
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
|
$where
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT $perPage OFFSET $offset";
|
||||||
|
|
||||||
|
if (!empty($params)) {
|
||||||
|
$stmt = $conn->prepare($sql);
|
||||||
|
$stmt->bind_param($types, ...$params);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
} else {
|
||||||
|
$result = $conn->query($sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
$auditLogs = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$auditLogs[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$usersResult = $conn->query("SELECT user_id, username, display_name FROM users ORDER BY display_name");
|
||||||
|
$users = [];
|
||||||
|
while ($row = $usersResult->fetch_assoc()) {
|
||||||
|
$users[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
include 'views/admin/AuditLogView.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/admin/api-keys':
|
||||||
|
requireAdmin($currentUser);
|
||||||
|
require_once 'models/ApiKeyModel.php';
|
||||||
|
$apiKeyModel = new ApiKeyModel($conn);
|
||||||
|
$apiKeys = $apiKeyModel->getAllKeys();
|
||||||
|
include 'views/admin/ApiKeysView.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/admin/user-activity':
|
||||||
|
requireAdmin($currentUser);
|
||||||
|
|
||||||
|
$dateRange = [
|
||||||
|
'from' => $_GET['date_from'] ?? date('Y-m-d', strtotime('-30 days')),
|
||||||
|
'to' => $_GET['date_to'] ?? date('Y-m-d')
|
||||||
|
];
|
||||||
|
|
||||||
|
// Optimized query using LEFT JOINs with aggregated subqueries instead of correlated subqueries
|
||||||
|
// This eliminates N+1 query pattern and runs much faster with many users
|
||||||
|
$sql = "SELECT
|
||||||
|
u.user_id, u.username, u.display_name, u.is_admin,
|
||||||
|
COALESCE(tc.tickets_created, 0) as tickets_created,
|
||||||
|
COALESCE(tr.tickets_resolved, 0) as tickets_resolved,
|
||||||
|
COALESCE(cm.comments_added, 0) as comments_added,
|
||||||
|
COALESCE(ta.tickets_assigned, 0) as tickets_assigned,
|
||||||
|
al.last_activity
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT created_by, COUNT(*) as tickets_created
|
||||||
|
FROM tickets
|
||||||
|
WHERE DATE(created_at) BETWEEN ? AND ?
|
||||||
|
GROUP BY created_by
|
||||||
|
) tc ON u.user_id = tc.created_by
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT assigned_to, COUNT(*) as tickets_resolved
|
||||||
|
FROM tickets
|
||||||
|
WHERE status = 'Closed' AND DATE(updated_at) BETWEEN ? AND ?
|
||||||
|
GROUP BY assigned_to
|
||||||
|
) tr ON u.user_id = tr.assigned_to
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT user_id, COUNT(*) as comments_added
|
||||||
|
FROM ticket_comments
|
||||||
|
WHERE DATE(created_at) BETWEEN ? AND ?
|
||||||
|
GROUP BY user_id
|
||||||
|
) cm ON u.user_id = cm.user_id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT assigned_to, COUNT(*) as tickets_assigned
|
||||||
|
FROM tickets
|
||||||
|
WHERE DATE(created_at) BETWEEN ? AND ?
|
||||||
|
GROUP BY assigned_to
|
||||||
|
) ta ON u.user_id = ta.assigned_to
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT user_id, MAX(created_at) as last_activity
|
||||||
|
FROM audit_log
|
||||||
|
GROUP BY user_id
|
||||||
|
) al ON u.user_id = al.user_id
|
||||||
|
ORDER BY tickets_created DESC, tickets_resolved DESC";
|
||||||
|
|
||||||
|
$stmt = $conn->prepare($sql);
|
||||||
|
$stmt->bind_param(
|
||||||
|
'ssssssss',
|
||||||
|
$dateRange['from'],
|
||||||
|
$dateRange['to'],
|
||||||
|
$dateRange['from'],
|
||||||
|
$dateRange['to'],
|
||||||
|
$dateRange['from'],
|
||||||
|
$dateRange['to'],
|
||||||
|
$dateRange['from'],
|
||||||
|
$dateRange['to']
|
||||||
|
);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
$userStats = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$userStats[] = $row;
|
||||||
|
}
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
include 'views/admin/UserActivityView.php';
|
||||||
|
break;
|
||||||
|
|
||||||
// Legacy support for old URLs
|
// Legacy support for old URLs
|
||||||
case $requestPath == '/dashboard.php':
|
case $requestPath == '/dashboard.php':
|
||||||
header("Location: /");
|
header("Location: /");
|
||||||
exit;
|
exit;
|
||||||
|
|
||||||
case preg_match('/^\/ticket\.php/', $requestPath) && isset($_GET['id']):
|
case preg_match('/^\/ticket\.php/', $requestPath) && isset($_GET['id']):
|
||||||
header("Location: /ticket/" . $_GET['id']);
|
$legacyId = (string)$_GET['id'];
|
||||||
|
if (ctype_digit($legacyId) && (int)$legacyId > 0) {
|
||||||
|
header("Location: /ticket/" . $legacyId);
|
||||||
|
} else {
|
||||||
|
header("Location: /");
|
||||||
|
}
|
||||||
exit;
|
exit;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// 404 Not Found
|
http_response_code(404);
|
||||||
header("HTTP/1.0 404 Not Found");
|
include __DIR__ . '/views/error_404.php';
|
||||||
echo '404 Page Not Found';
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,4 +425,3 @@ switch (true) {
|
|||||||
if (isset($conn)) {
|
if (isset($conn)) {
|
||||||
$conn->close();
|
$conn->close();
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ApiKeyAuth - Handles API key authentication for external services
|
* ApiKeyAuth - Handles API key authentication for external services
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
|
require_once dirname(__DIR__) . '/models/ApiKeyModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||||
|
|
||||||
class ApiKeyAuth {
|
class ApiKeyAuth
|
||||||
|
{
|
||||||
private $apiKeyModel;
|
private $apiKeyModel;
|
||||||
private $userModel;
|
private $userModel;
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
$this->apiKeyModel = new ApiKeyModel($conn);
|
$this->apiKeyModel = new ApiKeyModel($conn);
|
||||||
$this->userModel = new UserModel($conn);
|
$this->userModel = new UserModel($conn);
|
||||||
@@ -22,7 +26,8 @@ class ApiKeyAuth {
|
|||||||
* @return array User data for system user
|
* @return array User data for system user
|
||||||
* @throws Exception if authentication fails
|
* @throws Exception if authentication fails
|
||||||
*/
|
*/
|
||||||
public function authenticate() {
|
public function authenticate()
|
||||||
|
{
|
||||||
// Get Authorization header
|
// Get Authorization header
|
||||||
$authHeader = $this->getAuthorizationHeader();
|
$authHeader = $this->getAuthorizationHeader();
|
||||||
|
|
||||||
@@ -67,7 +72,8 @@ class ApiKeyAuth {
|
|||||||
*
|
*
|
||||||
* @return string|null Authorization header value
|
* @return string|null Authorization header value
|
||||||
*/
|
*/
|
||||||
private function getAuthorizationHeader() {
|
private function getAuthorizationHeader()
|
||||||
|
{
|
||||||
// Try different header formats
|
// Try different header formats
|
||||||
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
|
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
|
||||||
return $_SERVER['HTTP_AUTHORIZATION'];
|
return $_SERVER['HTTP_AUTHORIZATION'];
|
||||||
@@ -96,7 +102,8 @@ class ApiKeyAuth {
|
|||||||
*
|
*
|
||||||
* @param string $message Error message
|
* @param string $message Error message
|
||||||
*/
|
*/
|
||||||
private function sendUnauthorized($message) {
|
private function sendUnauthorized($message)
|
||||||
|
{
|
||||||
header('HTTP/1.1 401 Unauthorized');
|
header('HTTP/1.1 401 Unauthorized');
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode([
|
echo json_encode([
|
||||||
@@ -111,7 +118,8 @@ class ApiKeyAuth {
|
|||||||
*
|
*
|
||||||
* @return array|null User data or null if not authenticated
|
* @return array|null User data or null if not authenticated
|
||||||
*/
|
*/
|
||||||
public function verifyOptional() {
|
public function verifyOptional()
|
||||||
|
{
|
||||||
$authHeader = $this->getAuthorizationHeader();
|
$authHeader = $this->getAuthorizationHeader();
|
||||||
|
|
||||||
if (empty($authHeader)) {
|
if (empty($authHeader)) {
|
||||||
|
|||||||
@@ -1,40 +1,90 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AuthMiddleware - Handles authentication via Authelia forward auth headers
|
* AuthMiddleware - Handles authentication via Authelia forward auth headers
|
||||||
*/
|
*/
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||||
|
|
||||||
class AuthMiddleware {
|
class AuthMiddleware
|
||||||
|
{
|
||||||
private $userModel;
|
private $userModel;
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
$this->userModel = new UserModel($conn);
|
$this->userModel = new UserModel($conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log security event for authentication failures
|
||||||
|
*
|
||||||
|
* @param string $event Event type (e.g., 'auth_required', 'access_denied', 'session_expired')
|
||||||
|
* @param array $context Additional context data
|
||||||
|
*/
|
||||||
|
private function logSecurityEvent(string $event, array $context = []): void
|
||||||
|
{
|
||||||
|
$logData = [
|
||||||
|
'event' => $event,
|
||||||
|
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
|
||||||
|
'forwarded_for' => $_SERVER['HTTP_X_FORWARDED_FOR'] ?? null,
|
||||||
|
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
|
||||||
|
'request_uri' => $_SERVER['REQUEST_URI'] ?? 'unknown',
|
||||||
|
'request_method' => $_SERVER['REQUEST_METHOD'] ?? 'unknown',
|
||||||
|
'timestamp' => date('c')
|
||||||
|
];
|
||||||
|
|
||||||
|
// Merge additional context
|
||||||
|
$logData = array_merge($logData, $context);
|
||||||
|
|
||||||
|
// Remove null values for cleaner logs
|
||||||
|
$logData = array_filter($logData, fn($v) => $v !== null);
|
||||||
|
|
||||||
|
// Format log message
|
||||||
|
$message = sprintf(
|
||||||
|
"[SECURITY] %s: %s",
|
||||||
|
strtoupper($event),
|
||||||
|
json_encode($logData, JSON_UNESCAPED_SLASHES)
|
||||||
|
);
|
||||||
|
|
||||||
|
error_log($message);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticate user from Authelia forward auth headers
|
* Authenticate user from Authelia forward auth headers
|
||||||
*
|
*
|
||||||
* @return array User data array
|
* @return array User data array
|
||||||
* @throws Exception if authentication fails
|
* @throws Exception if authentication fails
|
||||||
*/
|
*/
|
||||||
public function authenticate() {
|
public function authenticate()
|
||||||
|
{
|
||||||
// Start session if not already started with secure settings
|
// Start session if not already started with secure settings
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
// Configure secure session settings
|
// Configure secure session settings
|
||||||
ini_set('session.cookie_httponly', 1);
|
ini_set('session.cookie_httponly', 1);
|
||||||
ini_set('session.cookie_secure', 1); // Requires HTTPS
|
ini_set('session.cookie_secure', 1); // Requires HTTPS
|
||||||
ini_set('session.cookie_samesite', 'Strict');
|
ini_set('session.cookie_samesite', 'Lax'); // Lax allows redirects from Authelia
|
||||||
ini_set('session.use_strict_mode', 1);
|
ini_set('session.use_strict_mode', 1);
|
||||||
|
$sessionTimeout = $GLOBALS['config']['SESSION_TIMEOUT'] ?? 18000;
|
||||||
|
ini_set('session.gc_maxlifetime', $sessionTimeout);
|
||||||
|
ini_set('session.cookie_lifetime', 0); // Until browser closes
|
||||||
|
|
||||||
session_start();
|
session_start();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is already authenticated in session
|
// Check if user is already authenticated in session
|
||||||
if (isset($_SESSION['user']) && isset($_SESSION['user']['user_id'])) {
|
if (isset($_SESSION['user']) && isset($_SESSION['user']['user_id'])) {
|
||||||
// Verify session hasn't expired (5 hour timeout)
|
// Verify session hasn't expired
|
||||||
if (isset($_SESSION['last_activity']) && (time() - $_SESSION['last_activity'] > 18000)) {
|
$sessionTimeout = $GLOBALS['config']['SESSION_TIMEOUT'] ?? 18000;
|
||||||
|
if (isset($_SESSION['last_activity']) && (time() - $_SESSION['last_activity'] > $sessionTimeout)) {
|
||||||
|
// Log session expiration
|
||||||
|
$this->logSecurityEvent('session_expired', [
|
||||||
|
'username' => $_SESSION['user']['username'] ?? 'unknown',
|
||||||
|
'user_id' => $_SESSION['user']['user_id'] ?? null,
|
||||||
|
'session_age_seconds' => time() - $_SESSION['last_activity']
|
||||||
|
]);
|
||||||
|
|
||||||
// Session expired, clear it
|
// Session expired, clear it
|
||||||
session_unset();
|
session_unset();
|
||||||
session_destroy();
|
session_destroy();
|
||||||
@@ -92,7 +142,8 @@ class AuthMiddleware {
|
|||||||
* @param string $header Header name
|
* @param string $header Header name
|
||||||
* @return string|null Header value or null if not set
|
* @return string|null Header value or null if not set
|
||||||
*/
|
*/
|
||||||
private function getHeader($header) {
|
private function getHeader($header)
|
||||||
|
{
|
||||||
if (isset($_SERVER[$header])) {
|
if (isset($_SERVER[$header])) {
|
||||||
return $_SERVER[$header];
|
return $_SERVER[$header];
|
||||||
}
|
}
|
||||||
@@ -105,13 +156,20 @@ class AuthMiddleware {
|
|||||||
* @param string $groups Comma-separated group names
|
* @param string $groups Comma-separated group names
|
||||||
* @return bool True if user has access
|
* @return bool True if user has access
|
||||||
*/
|
*/
|
||||||
private function checkGroupAccess($groups) {
|
private function checkGroupAccess($groups)
|
||||||
|
{
|
||||||
if (empty($groups)) {
|
if (empty($groups)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for admin or employee group membership
|
// Check for admin or employee group membership
|
||||||
$userGroups = array_map('trim', explode(',', strtolower($groups)));
|
// Filter to safe characters only to prevent header injection attacks
|
||||||
|
$userGroups = array_filter(
|
||||||
|
array_map('trim', explode(',', strtolower($groups))),
|
||||||
|
function ($g) {
|
||||||
|
return preg_match('/^[a-z0-9_\-]+$/', $g);
|
||||||
|
}
|
||||||
|
);
|
||||||
$requiredGroups = ['admin', 'employee'];
|
$requiredGroups = ['admin', 'employee'];
|
||||||
|
|
||||||
return !empty(array_intersect($userGroups, $requiredGroups));
|
return !empty(array_intersect($userGroups, $requiredGroups));
|
||||||
@@ -120,7 +178,13 @@ class AuthMiddleware {
|
|||||||
/**
|
/**
|
||||||
* Redirect to Authelia login
|
* Redirect to Authelia login
|
||||||
*/
|
*/
|
||||||
private function redirectToAuth() {
|
private function redirectToAuth()
|
||||||
|
{
|
||||||
|
// Log unauthenticated access attempt
|
||||||
|
$this->logSecurityEvent('auth_required', [
|
||||||
|
'reason' => 'no_auth_headers'
|
||||||
|
]);
|
||||||
|
|
||||||
// Redirect to the auth endpoint (Authelia will handle the redirect back)
|
// Redirect to the auth endpoint (Authelia will handle the redirect back)
|
||||||
header('HTTP/1.1 401 Unauthorized');
|
header('HTTP/1.1 401 Unauthorized');
|
||||||
echo '<!DOCTYPE html>
|
echo '<!DOCTYPE html>
|
||||||
@@ -184,7 +248,16 @@ class AuthMiddleware {
|
|||||||
* @param string $username Username
|
* @param string $username Username
|
||||||
* @param string $groups User groups
|
* @param string $groups User groups
|
||||||
*/
|
*/
|
||||||
private function showAccessDenied($username, $groups) {
|
private function showAccessDenied($username, $groups)
|
||||||
|
{
|
||||||
|
// Log access denied event with user details
|
||||||
|
$this->logSecurityEvent('access_denied', [
|
||||||
|
'username' => $username,
|
||||||
|
'groups' => $groups ?: 'none',
|
||||||
|
'required_groups' => 'admin,employee',
|
||||||
|
'reason' => 'insufficient_group_membership'
|
||||||
|
]);
|
||||||
|
|
||||||
header('HTTP/1.1 403 Forbidden');
|
header('HTTP/1.1 403 Forbidden');
|
||||||
echo '<!DOCTYPE html>
|
echo '<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -247,7 +320,8 @@ class AuthMiddleware {
|
|||||||
*
|
*
|
||||||
* @return array|null User data or null if not authenticated
|
* @return array|null User data or null if not authenticated
|
||||||
*/
|
*/
|
||||||
public static function getCurrentUser() {
|
public static function getCurrentUser()
|
||||||
|
{
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
session_start();
|
session_start();
|
||||||
}
|
}
|
||||||
@@ -258,7 +332,8 @@ class AuthMiddleware {
|
|||||||
/**
|
/**
|
||||||
* Logout current user
|
* Logout current user
|
||||||
*/
|
*/
|
||||||
public static function logout() {
|
public static function logout()
|
||||||
|
{
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
session_start();
|
session_start();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSRF Protection Middleware
|
* CSRF Protection Middleware
|
||||||
* Generates and validates CSRF tokens for all state-changing operations
|
* Generates and validates CSRF tokens for all state-changing operations
|
||||||
*/
|
*/
|
||||||
class CsrfMiddleware {
|
class CsrfMiddleware
|
||||||
private static $tokenName = 'csrf_token';
|
{
|
||||||
private static $tokenTime = 'csrf_token_time';
|
private static string $tokenName = 'csrf_token';
|
||||||
private static $tokenLifetime = 3600; // 1 hour
|
private static string $tokenTime = 'csrf_token_time';
|
||||||
|
private static int $tokenLifetime = 3600; // 1 hour
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a new CSRF token
|
* Generate a new CSRF token
|
||||||
*/
|
*/
|
||||||
public static function generateToken() {
|
public static function generateToken(): string
|
||||||
|
{
|
||||||
$_SESSION[self::$tokenName] = bin2hex(random_bytes(32));
|
$_SESSION[self::$tokenName] = bin2hex(random_bytes(32));
|
||||||
$_SESSION[self::$tokenTime] = time();
|
$_SESSION[self::$tokenTime] = time();
|
||||||
return $_SESSION[self::$tokenName];
|
return $_SESSION[self::$tokenName];
|
||||||
@@ -20,7 +23,8 @@ class CsrfMiddleware {
|
|||||||
/**
|
/**
|
||||||
* Get current CSRF token, regenerate if expired
|
* Get current CSRF token, regenerate if expired
|
||||||
*/
|
*/
|
||||||
public static function getToken() {
|
public static function getToken(): string
|
||||||
|
{
|
||||||
if (!isset($_SESSION[self::$tokenName]) || self::isTokenExpired()) {
|
if (!isset($_SESSION[self::$tokenName]) || self::isTokenExpired()) {
|
||||||
return self::generateToken();
|
return self::generateToken();
|
||||||
}
|
}
|
||||||
@@ -30,7 +34,8 @@ class CsrfMiddleware {
|
|||||||
/**
|
/**
|
||||||
* Validate CSRF token (constant-time comparison)
|
* Validate CSRF token (constant-time comparison)
|
||||||
*/
|
*/
|
||||||
public static function validateToken($token) {
|
public static function validateToken(string $token): bool
|
||||||
|
{
|
||||||
if (!isset($_SESSION[self::$tokenName])) {
|
if (!isset($_SESSION[self::$tokenName])) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -44,12 +49,25 @@ class CsrfMiddleware {
|
|||||||
return hash_equals($_SESSION[self::$tokenName], $token);
|
return hash_equals($_SESSION[self::$tokenName], $token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate the CSRF token after a successful validated POST.
|
||||||
|
* Call this after validateToken() returns true, then include
|
||||||
|
* the new token in the JSON response as 'csrf_token' so the
|
||||||
|
* client can update window.CSRF_TOKEN for subsequent requests.
|
||||||
|
*
|
||||||
|
* @return string The new token
|
||||||
|
*/
|
||||||
|
public static function rotateToken(): string
|
||||||
|
{
|
||||||
|
return self::generateToken();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if token is expired
|
* Check if token is expired
|
||||||
*/
|
*/
|
||||||
private static function isTokenExpired() {
|
private static function isTokenExpired(): bool
|
||||||
|
{
|
||||||
return !isset($_SESSION[self::$tokenTime]) ||
|
return !isset($_SESSION[self::$tokenTime]) ||
|
||||||
(time() - $_SESSION[self::$tokenTime]) > self::$tokenLifetime;
|
(time() - $_SESSION[self::$tokenTime]) > self::$tokenLifetime;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate Limiting Middleware
|
||||||
|
*
|
||||||
|
* Implements both session-based and IP-based rate limiting to prevent abuse.
|
||||||
|
* IP-based limiting prevents attackers from bypassing limits by creating new sessions.
|
||||||
|
*/
|
||||||
|
class RateLimitMiddleware
|
||||||
|
{
|
||||||
|
// Default limits
|
||||||
|
public const DEFAULT_LIMIT = 100; // requests per window (session)
|
||||||
|
public const API_LIMIT = 60; // API requests per window (session)
|
||||||
|
public const IP_LIMIT = 300; // IP-based requests per window (more generous)
|
||||||
|
public const IP_API_LIMIT = 120; // IP-based API requests per window
|
||||||
|
public const WINDOW_SECONDS = 60; // 1 minute window
|
||||||
|
|
||||||
|
// Directory for IP rate limit storage
|
||||||
|
private static ?string $rateLimitDir = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the rate limit storage directory
|
||||||
|
*
|
||||||
|
* @return string Path to rate limit storage directory
|
||||||
|
*/
|
||||||
|
private static function getRateLimitDir(): string
|
||||||
|
{
|
||||||
|
if (self::$rateLimitDir === null) {
|
||||||
|
self::$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit';
|
||||||
|
if (!is_dir(self::$rateLimitDir)) {
|
||||||
|
mkdir(self::$rateLimitDir, 0755, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self::$rateLimitDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the client's IP address
|
||||||
|
*
|
||||||
|
* @return string Client IP address
|
||||||
|
*/
|
||||||
|
private static function getClientIp(): string
|
||||||
|
{
|
||||||
|
// Check for forwarded IP (behind proxy/load balancer)
|
||||||
|
$headers = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP'];
|
||||||
|
foreach ($headers as $header) {
|
||||||
|
if (!empty($_SERVER[$header])) {
|
||||||
|
// Take the first IP in a comma-separated list
|
||||||
|
$ips = explode(',', $_SERVER[$header]);
|
||||||
|
$ip = trim($ips[0]);
|
||||||
|
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
||||||
|
return $ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check IP-based rate limit
|
||||||
|
*
|
||||||
|
* @param string $type 'default' or 'api'
|
||||||
|
* @return bool True if request is allowed, false if rate limited
|
||||||
|
*/
|
||||||
|
private static function checkIpRateLimit(string $type = 'default'): bool
|
||||||
|
{
|
||||||
|
$ip = self::getClientIp();
|
||||||
|
$limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT;
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
// Create a hash of the IP for the filename (security + filesystem safety)
|
||||||
|
$ipHash = hash('sha256', $ip . '_' . $type);
|
||||||
|
$filePath = self::getRateLimitDir() . '/' . $ipHash . '.json';
|
||||||
|
|
||||||
|
// Load existing rate data
|
||||||
|
$rateData = ['count' => 0, 'window_start' => $now];
|
||||||
|
if (file_exists($filePath)) {
|
||||||
|
$content = @file_get_contents($filePath);
|
||||||
|
if ($content !== false) {
|
||||||
|
$decoded = json_decode($content, true);
|
||||||
|
if (is_array($decoded)) {
|
||||||
|
$rateData = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if window has expired
|
||||||
|
if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) {
|
||||||
|
$rateData = ['count' => 0, 'window_start' => $now];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment count
|
||||||
|
$rateData['count']++;
|
||||||
|
|
||||||
|
// Save updated data
|
||||||
|
@file_put_contents($filePath, json_encode($rateData), LOCK_EX);
|
||||||
|
|
||||||
|
// Check if over limit
|
||||||
|
return $rateData['count'] <= $limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up old rate limit files (call periodically)
|
||||||
|
*
|
||||||
|
* Uses DirectoryIterator instead of glob() for better memory efficiency.
|
||||||
|
* A dedicated cron script (cron/cleanup_ratelimit.php) should also run for reliable cleanup.
|
||||||
|
*/
|
||||||
|
public static function cleanupOldFiles(): void
|
||||||
|
{
|
||||||
|
$dir = self::getRateLimitDir();
|
||||||
|
$lockFile = $dir . '/.cleanup.lock';
|
||||||
|
$now = time();
|
||||||
|
$maxAge = self::WINDOW_SECONDS * 2; // Files older than 2 windows
|
||||||
|
$maxLockAge = 60; // Release stale locks after 60 seconds
|
||||||
|
|
||||||
|
// Check for existing lock to prevent concurrent cleanups
|
||||||
|
if (file_exists($lockFile)) {
|
||||||
|
$lockAge = $now - filemtime($lockFile);
|
||||||
|
if ($lockAge < $maxLockAge) {
|
||||||
|
return; // Cleanup already in progress
|
||||||
|
}
|
||||||
|
@unlink($lockFile); // Stale lock
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to acquire lock
|
||||||
|
if (!@touch($lockFile)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$iterator = new DirectoryIterator($dir);
|
||||||
|
$deleted = 0;
|
||||||
|
$maxDeletes = 50; // Limit deletions per request to avoid blocking
|
||||||
|
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if ($deleted >= $maxDeletes) {
|
||||||
|
break; // Let cron handle the rest
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($file->isDot() || !$file->isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = $file->getFilename();
|
||||||
|
if ($filename === '.cleanup.lock' || !str_ends_with($filename, '.json')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($now - $file->getMTime() > $maxAge) {
|
||||||
|
if (@unlink($file->getPathname())) {
|
||||||
|
$deleted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
@unlink($lockFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check rate limit for current request (both session and IP)
|
||||||
|
*
|
||||||
|
* @param string $type 'default' or 'api'
|
||||||
|
* @return bool True if request is allowed, false if rate limited
|
||||||
|
*/
|
||||||
|
public static function check(string $type = 'default'): bool
|
||||||
|
{
|
||||||
|
// First check IP-based rate limit (prevents session bypass)
|
||||||
|
if (!self::checkIpRateLimit($type)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then check session-based rate limit
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
|
||||||
|
$key = 'rate_limit_' . $type;
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
// Initialize rate limit tracking
|
||||||
|
if (!isset($_SESSION[$key])) {
|
||||||
|
$_SESSION[$key] = [
|
||||||
|
'count' => 0,
|
||||||
|
'window_start' => $now
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rateData = &$_SESSION[$key];
|
||||||
|
|
||||||
|
// Check if window has expired
|
||||||
|
if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) {
|
||||||
|
// Reset for new window
|
||||||
|
$rateData['count'] = 0;
|
||||||
|
$rateData['window_start'] = $now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment request count
|
||||||
|
$rateData['count']++;
|
||||||
|
|
||||||
|
// Check if over limit
|
||||||
|
if ($rateData['count'] > $limit) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply rate limiting and send error response if exceeded
|
||||||
|
*
|
||||||
|
* @param string $type 'default' or 'api'
|
||||||
|
* @param bool $addHeaders Whether to add rate limit headers to response
|
||||||
|
*/
|
||||||
|
public static function apply(string $type = 'default', bool $addHeaders = true): void
|
||||||
|
{
|
||||||
|
// Periodically clean up old rate limit files (2% chance per request)
|
||||||
|
// Note: For production, use cron/cleanup_ratelimit.php for reliable cleanup
|
||||||
|
if (mt_rand(1, 50) === 1) {
|
||||||
|
self::cleanupOldFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!self::check($type)) {
|
||||||
|
http_response_code(429);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
header('Retry-After: ' . self::WINDOW_SECONDS);
|
||||||
|
if ($addHeaders) {
|
||||||
|
self::addHeaders($type);
|
||||||
|
}
|
||||||
|
echo json_encode([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Rate limit exceeded. Please try again later.',
|
||||||
|
'retry_after' => self::WINDOW_SECONDS
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add rate limit headers to successful responses
|
||||||
|
if ($addHeaders) {
|
||||||
|
self::addHeaders($type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current rate limit status
|
||||||
|
*
|
||||||
|
* @param string $type 'default' or 'api'
|
||||||
|
* @return array Rate limit status
|
||||||
|
*/
|
||||||
|
public static function getStatus(string $type = 'default'): array
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = $type === 'api' ? self::API_LIMIT : self::DEFAULT_LIMIT;
|
||||||
|
$key = 'rate_limit_' . $type;
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
if (!isset($_SESSION[$key])) {
|
||||||
|
return [
|
||||||
|
'limit' => $limit,
|
||||||
|
'remaining' => $limit,
|
||||||
|
'reset' => $now + self::WINDOW_SECONDS
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rateData = $_SESSION[$key];
|
||||||
|
|
||||||
|
// Check if window has expired
|
||||||
|
if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) {
|
||||||
|
return [
|
||||||
|
'limit' => $limit,
|
||||||
|
'remaining' => $limit,
|
||||||
|
'reset' => $now + self::WINDOW_SECONDS
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'limit' => $limit,
|
||||||
|
'remaining' => max(0, $limit - $rateData['count']),
|
||||||
|
'reset' => $rateData['window_start'] + self::WINDOW_SECONDS
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add rate limit headers to response
|
||||||
|
*
|
||||||
|
* @param string $type 'default' or 'api'
|
||||||
|
*/
|
||||||
|
public static function addHeaders(string $type = 'default'): void
|
||||||
|
{
|
||||||
|
$status = self::getStatus($type);
|
||||||
|
header('X-RateLimit-Limit: ' . $status['limit']);
|
||||||
|
header('X-RateLimit-Remaining: ' . $status['remaining']);
|
||||||
|
header('X-RateLimit-Reset: ' . $status['reset']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security Headers Middleware
|
||||||
|
*
|
||||||
|
* Applies security-related HTTP headers to all responses.
|
||||||
|
*/
|
||||||
|
class SecurityHeadersMiddleware
|
||||||
|
{
|
||||||
|
private static ?string $nonce = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate or retrieve the CSP nonce for this request
|
||||||
|
*
|
||||||
|
* @return string The nonce value
|
||||||
|
*/
|
||||||
|
public static function getNonce(): string
|
||||||
|
{
|
||||||
|
if (self::$nonce === null) {
|
||||||
|
self::$nonce = base64_encode(random_bytes(16));
|
||||||
|
}
|
||||||
|
return self::$nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply security headers to the response
|
||||||
|
*/
|
||||||
|
public static function apply(): void
|
||||||
|
{
|
||||||
|
$nonce = self::getNonce();
|
||||||
|
|
||||||
|
// Content Security Policy - restricts where resources can be loaded from
|
||||||
|
// Using nonces for scripts to prevent XSS attacks while allowing inline scripts with valid nonces
|
||||||
|
// All inline event handlers have been refactored to use addEventListener with data-action attributes
|
||||||
|
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net;");
|
||||||
|
|
||||||
|
// Prevent clickjacking by disallowing framing
|
||||||
|
header("X-Frame-Options: DENY");
|
||||||
|
|
||||||
|
// Prevent MIME type sniffing
|
||||||
|
header("X-Content-Type-Options: nosniff");
|
||||||
|
|
||||||
|
// Enable XSS filtering in older browsers
|
||||||
|
header("X-XSS-Protection: 1; mode=block");
|
||||||
|
|
||||||
|
// Control referrer information sent with requests
|
||||||
|
header("Referrer-Policy: strict-origin-when-cross-origin");
|
||||||
|
|
||||||
|
// Permissions Policy - disable unnecessary browser features
|
||||||
|
header("Permissions-Policy: geolocation=(), microphone=(), camera=()");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
-- Create users table for SSO integration
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
user_id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
username VARCHAR(100) UNIQUE NOT NULL,
|
|
||||||
display_name VARCHAR(255),
|
|
||||||
email VARCHAR(255),
|
|
||||||
groups TEXT,
|
|
||||||
is_admin BOOLEAN DEFAULT FALSE,
|
|
||||||
last_login TIMESTAMP NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
INDEX idx_username (username)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
|
|
||||||
-- Insert system user for hwmonDaemon
|
|
||||||
INSERT INTO users (username, display_name, email, groups, is_admin, created_at)
|
|
||||||
VALUES ('system', 'System', 'system@lotusguild.org', '', FALSE, NOW())
|
|
||||||
ON DUPLICATE KEY UPDATE username = username;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
-- Create API keys table for external service authentication
|
|
||||||
CREATE TABLE IF NOT EXISTS api_keys (
|
|
||||||
api_key_id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
key_name VARCHAR(100) NOT NULL,
|
|
||||||
key_hash VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
key_prefix VARCHAR(20) NOT NULL,
|
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
|
||||||
created_by INT,
|
|
||||||
last_used TIMESTAMP NULL,
|
|
||||||
expires_at TIMESTAMP NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL,
|
|
||||||
INDEX idx_key_hash (key_hash),
|
|
||||||
INDEX idx_is_active (is_active)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
-- Create audit log table for tracking all user actions
|
|
||||||
CREATE TABLE IF NOT EXISTS audit_log (
|
|
||||||
audit_id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
user_id INT,
|
|
||||||
action_type VARCHAR(50) NOT NULL,
|
|
||||||
entity_type VARCHAR(50) NOT NULL,
|
|
||||||
entity_id VARCHAR(50),
|
|
||||||
details JSON,
|
|
||||||
ip_address VARCHAR(45),
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE SET NULL,
|
|
||||||
INDEX idx_user_id (user_id),
|
|
||||||
INDEX idx_created_at (created_at),
|
|
||||||
INDEX idx_entity (entity_type, entity_id),
|
|
||||||
INDEX idx_action_type (action_type)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
-- Add user tracking columns to tickets table
|
|
||||||
ALTER TABLE tickets
|
|
||||||
ADD COLUMN IF NOT EXISTS created_by INT,
|
|
||||||
ADD COLUMN IF NOT EXISTS updated_by INT,
|
|
||||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP NULL;
|
|
||||||
|
|
||||||
-- Add foreign key constraints if they don't exist
|
|
||||||
SET @fk_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
|
|
||||||
WHERE CONSTRAINT_NAME = 'fk_tickets_created_by'
|
|
||||||
AND TABLE_NAME = 'tickets'
|
|
||||||
AND TABLE_SCHEMA = DATABASE());
|
|
||||||
|
|
||||||
SET @sql = IF(@fk_exists = 0,
|
|
||||||
'ALTER TABLE tickets ADD CONSTRAINT fk_tickets_created_by FOREIGN KEY (created_by) REFERENCES users(user_id) ON DELETE SET NULL',
|
|
||||||
'SELECT "Foreign key fk_tickets_created_by already exists"');
|
|
||||||
PREPARE stmt FROM @sql;
|
|
||||||
EXECUTE stmt;
|
|
||||||
DEALLOCATE PREPARE stmt;
|
|
||||||
|
|
||||||
SET @fk_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
|
|
||||||
WHERE CONSTRAINT_NAME = 'fk_tickets_updated_by'
|
|
||||||
AND TABLE_NAME = 'tickets'
|
|
||||||
AND TABLE_SCHEMA = DATABASE());
|
|
||||||
|
|
||||||
SET @sql = IF(@fk_exists = 0,
|
|
||||||
'ALTER TABLE tickets ADD CONSTRAINT fk_tickets_updated_by FOREIGN KEY (updated_by) REFERENCES users(user_id) ON DELETE SET NULL',
|
|
||||||
'SELECT "Foreign key fk_tickets_updated_by already exists"');
|
|
||||||
PREPARE stmt FROM @sql;
|
|
||||||
EXECUTE stmt;
|
|
||||||
DEALLOCATE PREPARE stmt;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
-- Add user_id column to ticket_comments table
|
|
||||||
ALTER TABLE ticket_comments
|
|
||||||
ADD COLUMN IF NOT EXISTS user_id INT;
|
|
||||||
|
|
||||||
-- Add foreign key constraint if it doesn't exist
|
|
||||||
SET @fk_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
|
|
||||||
WHERE CONSTRAINT_NAME = 'fk_comments_user_id'
|
|
||||||
AND TABLE_NAME = 'ticket_comments'
|
|
||||||
AND TABLE_SCHEMA = DATABASE());
|
|
||||||
|
|
||||||
SET @sql = IF(@fk_exists = 0,
|
|
||||||
'ALTER TABLE ticket_comments ADD CONSTRAINT fk_comments_user_id FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE SET NULL',
|
|
||||||
'SELECT "Foreign key fk_comments_user_id already exists"');
|
|
||||||
PREPARE stmt FROM @sql;
|
|
||||||
EXECUTE stmt;
|
|
||||||
DEALLOCATE PREPARE stmt;
|
|
||||||
|
|
||||||
-- Update existing comments to reference jared user (first admin)
|
|
||||||
-- This will be done after jared user is created via web login
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
-- Add database indexes for performance optimization
|
|
||||||
-- Check and create index on tickets.status
|
|
||||||
SET @index_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
|
||||||
WHERE TABLE_NAME = 'tickets'
|
|
||||||
AND INDEX_NAME = 'idx_status'
|
|
||||||
AND TABLE_SCHEMA = DATABASE());
|
|
||||||
|
|
||||||
SET @sql = IF(@index_exists = 0,
|
|
||||||
'CREATE INDEX idx_status ON tickets(status)',
|
|
||||||
'SELECT "Index idx_status already exists"');
|
|
||||||
PREPARE stmt FROM @sql;
|
|
||||||
EXECUTE stmt;
|
|
||||||
DEALLOCATE PREPARE stmt;
|
|
||||||
|
|
||||||
-- Check and create index on tickets.priority
|
|
||||||
SET @index_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
|
||||||
WHERE TABLE_NAME = 'tickets'
|
|
||||||
AND INDEX_NAME = 'idx_priority'
|
|
||||||
AND TABLE_SCHEMA = DATABASE());
|
|
||||||
|
|
||||||
SET @sql = IF(@index_exists = 0,
|
|
||||||
'CREATE INDEX idx_priority ON tickets(priority)',
|
|
||||||
'SELECT "Index idx_priority already exists"');
|
|
||||||
PREPARE stmt FROM @sql;
|
|
||||||
EXECUTE stmt;
|
|
||||||
DEALLOCATE PREPARE stmt;
|
|
||||||
|
|
||||||
-- Check and create index on tickets.created_at
|
|
||||||
SET @index_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
|
||||||
WHERE TABLE_NAME = 'tickets'
|
|
||||||
AND INDEX_NAME = 'idx_tickets_created_at'
|
|
||||||
AND TABLE_SCHEMA = DATABASE());
|
|
||||||
|
|
||||||
SET @sql = IF(@index_exists = 0,
|
|
||||||
'CREATE INDEX idx_tickets_created_at ON tickets(created_at)',
|
|
||||||
'SELECT "Index idx_tickets_created_at already exists"');
|
|
||||||
PREPARE stmt FROM @sql;
|
|
||||||
EXECUTE stmt;
|
|
||||||
DEALLOCATE PREPARE stmt;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
-- Migration 007: Add ticket assignment functionality
|
|
||||||
-- Adds assigned_to column to tickets table
|
|
||||||
|
|
||||||
-- Add assigned_to column to tickets table
|
|
||||||
ALTER TABLE tickets
|
|
||||||
ADD COLUMN assigned_to INT NULL,
|
|
||||||
ADD CONSTRAINT fk_tickets_assigned_to
|
|
||||||
FOREIGN KEY (assigned_to)
|
|
||||||
REFERENCES users(user_id)
|
|
||||||
ON DELETE SET NULL;
|
|
||||||
|
|
||||||
-- Add index for performance
|
|
||||||
CREATE INDEX idx_assigned_to ON tickets(assigned_to);
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
-- Migration 008: Add status workflow management
|
|
||||||
-- Creates status_transitions table for workflow validation
|
|
||||||
|
|
||||||
-- Table to define allowed status transitions
|
|
||||||
CREATE TABLE status_transitions (
|
|
||||||
transition_id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
from_status VARCHAR(50) NOT NULL,
|
|
||||||
to_status VARCHAR(50) NOT NULL,
|
|
||||||
requires_comment BOOLEAN DEFAULT FALSE,
|
|
||||||
requires_admin BOOLEAN DEFAULT FALSE,
|
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE KEY unique_transition (from_status, to_status),
|
|
||||||
INDEX idx_from_status (from_status)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Insert default transitions
|
|
||||||
INSERT INTO status_transitions (from_status, to_status, requires_comment) VALUES
|
|
||||||
('Open', 'In Progress', FALSE),
|
|
||||||
('Open', 'Closed', TRUE),
|
|
||||||
('In Progress', 'Open', FALSE),
|
|
||||||
('In Progress', 'Closed', TRUE),
|
|
||||||
('Closed', 'Open', TRUE),
|
|
||||||
('Closed', 'In Progress', FALSE);
|
|
||||||
|
|
||||||
-- Add new status "Resolved"
|
|
||||||
INSERT INTO status_transitions (from_status, to_status, requires_comment) VALUES
|
|
||||||
('In Progress', 'Resolved', FALSE),
|
|
||||||
('Resolved', 'Closed', FALSE),
|
|
||||||
('Resolved', 'In Progress', TRUE),
|
|
||||||
('Open', 'Resolved', FALSE);
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
-- Migration 009: Add ticket templates
|
|
||||||
-- Creates ticket_templates table for reusable ticket templates
|
|
||||||
|
|
||||||
CREATE TABLE ticket_templates (
|
|
||||||
template_id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
template_name VARCHAR(100) NOT NULL,
|
|
||||||
title_template VARCHAR(255) NOT NULL,
|
|
||||||
description_template TEXT NOT NULL,
|
|
||||||
category VARCHAR(50),
|
|
||||||
type VARCHAR(50),
|
|
||||||
default_priority INT DEFAULT 4,
|
|
||||||
created_by INT,
|
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (created_by) REFERENCES users(user_id),
|
|
||||||
INDEX idx_template_name (template_name)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Insert default templates
|
|
||||||
INSERT INTO ticket_templates (template_name, title_template, description_template, category, type, default_priority) VALUES
|
|
||||||
('Hardware Failure', 'Hardware Failure: [Device Name]', 'Device: \nIssue: \nError Messages: \nTroubleshooting Done: ', 'Hardware', 'Problem', 2),
|
|
||||||
('Software Installation', 'Install [Software Name]', 'Software: \nVersion: \nLicense Key: \nInstallation Path: ', 'Software', 'Install', 3),
|
|
||||||
('Network Issue', 'Network Issue: [Brief Description]', 'Affected System: \nSymptoms: \nIP Address: \nConnectivity Tests: ', 'Hardware', 'Problem', 2),
|
|
||||||
('Maintenance Request', 'Scheduled Maintenance: [System Name]', 'System: \nMaintenance Type: \nScheduled Date: \nDowntime Expected: ', 'Hardware', 'Maintenance', 4);
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
-- Migration 009: Simplify status workflow
|
|
||||||
-- Removes "Resolved" status and adds "Pending" status
|
|
||||||
-- Keeps only: Open, Pending, In Progress, Closed
|
|
||||||
|
|
||||||
-- First, update any existing tickets with "Resolved" status to "Closed"
|
|
||||||
UPDATE tickets SET status = 'Closed' WHERE status = 'Resolved';
|
|
||||||
|
|
||||||
-- Delete all existing transitions with "Resolved"
|
|
||||||
DELETE FROM status_transitions WHERE from_status = 'Resolved' OR to_status = 'Resolved';
|
|
||||||
|
|
||||||
-- Clear all existing transitions to rebuild clean workflow
|
|
||||||
DELETE FROM status_transitions;
|
|
||||||
|
|
||||||
-- Define new simplified workflow with Pending status
|
|
||||||
-- OPEN transitions
|
|
||||||
INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin) VALUES
|
|
||||||
('Open', 'Pending', FALSE, FALSE), -- Waiting on external dependency
|
|
||||||
('Open', 'In Progress', FALSE, FALSE), -- Start work
|
|
||||||
('Open', 'Closed', TRUE, FALSE); -- Close without work (duplicate, won't fix, etc.)
|
|
||||||
|
|
||||||
-- PENDING transitions
|
|
||||||
INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin) VALUES
|
|
||||||
('Pending', 'Open', FALSE, FALSE), -- Unblock and reopen
|
|
||||||
('Pending', 'In Progress', FALSE, FALSE), -- Start work while pending
|
|
||||||
('Pending', 'Closed', TRUE, FALSE); -- Close while pending
|
|
||||||
|
|
||||||
-- IN PROGRESS transitions
|
|
||||||
INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin) VALUES
|
|
||||||
('In Progress', 'Open', FALSE, FALSE), -- Stop work, back to queue
|
|
||||||
('In Progress', 'Pending', FALSE, FALSE), -- Blocked by external dependency
|
|
||||||
('In Progress', 'Closed', TRUE, FALSE); -- Complete and close
|
|
||||||
|
|
||||||
-- CLOSED transitions
|
|
||||||
INSERT INTO status_transitions (from_status, to_status, requires_comment, requires_admin) VALUES
|
|
||||||
('Closed', 'Open', TRUE, FALSE), -- Reopen (requires explanation)
|
|
||||||
('Closed', 'In Progress', FALSE, FALSE); -- Reopen and start work immediately
|
|
||||||
|
|
||||||
-- Verify new transitions
|
|
||||||
SELECT 'New Status Transitions:' as info;
|
|
||||||
SELECT from_status, to_status, requires_comment, requires_admin
|
|
||||||
FROM status_transitions
|
|
||||||
WHERE is_active = TRUE
|
|
||||||
ORDER BY from_status, to_status;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
-- Migration 010: Add bulk operations tracking
|
|
||||||
-- Creates bulk_operations table for admin bulk actions
|
|
||||||
|
|
||||||
CREATE TABLE bulk_operations (
|
|
||||||
operation_id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
operation_type VARCHAR(50) NOT NULL,
|
|
||||||
ticket_ids TEXT NOT NULL, -- Comma-separated
|
|
||||||
performed_by INT NOT NULL,
|
|
||||||
parameters JSON,
|
|
||||||
status VARCHAR(20) DEFAULT 'pending',
|
|
||||||
total_tickets INT,
|
|
||||||
processed_tickets INT DEFAULT 0,
|
|
||||||
failed_tickets INT DEFAULT 0,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
completed_at TIMESTAMP NULL,
|
|
||||||
FOREIGN KEY (performed_by) REFERENCES users(user_id),
|
|
||||||
INDEX idx_performed_by (performed_by),
|
|
||||||
INDEX idx_created_at (created_at)
|
|
||||||
);
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
-- Migration 010: Expand status column to accommodate longer status names
|
|
||||||
-- The status column was likely VARCHAR(10) which can't fit "In Progress" or "Pending"
|
|
||||||
|
|
||||||
-- Check current column definition
|
|
||||||
SHOW COLUMNS FROM tickets LIKE 'status';
|
|
||||||
|
|
||||||
-- Expand the status column to accommodate longer status names
|
|
||||||
ALTER TABLE tickets
|
|
||||||
MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'Open';
|
|
||||||
|
|
||||||
-- Verify the change
|
|
||||||
SHOW COLUMNS FROM tickets LIKE 'status';
|
|
||||||
|
|
||||||
-- Show current status distribution
|
|
||||||
SELECT status, COUNT(*) as count
|
|
||||||
FROM tickets
|
|
||||||
GROUP BY status
|
|
||||||
ORDER BY status;
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
-- Migration 011: Create user_preferences table for persistent user settings
|
|
||||||
-- Stores user-specific preferences like rows per page, default filters, etc.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
user_id INT NOT NULL,
|
|
||||||
preference_key VARCHAR(100) NOT NULL,
|
|
||||||
preference_value TEXT,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE KEY unique_user_pref (user_id, preference_key),
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Default preferences for existing users
|
|
||||||
INSERT INTO user_preferences (user_id, preference_key, preference_value)
|
|
||||||
SELECT user_id, 'rows_per_page', '15' FROM users
|
|
||||||
WHERE user_id NOT IN (SELECT user_id FROM user_preferences WHERE preference_key = 'rows_per_page');
|
|
||||||
|
|
||||||
INSERT INTO user_preferences (user_id, preference_key, preference_value)
|
|
||||||
SELECT user_id, 'default_status_filters', 'Open,Pending,In Progress' FROM users
|
|
||||||
WHERE user_id NOT IN (SELECT user_id FROM user_preferences WHERE preference_key = 'default_status_filters');
|
|
||||||
|
|
||||||
INSERT INTO user_preferences (user_id, preference_key, preference_value)
|
|
||||||
SELECT user_id, 'table_density', 'normal' FROM users
|
|
||||||
WHERE user_id NOT IN (SELECT user_id FROM user_preferences WHERE preference_key = 'table_density');
|
|
||||||
|
|
||||||
INSERT INTO user_preferences (user_id, preference_key, preference_value)
|
|
||||||
SELECT user_id, 'notifications_enabled', '1' FROM users
|
|
||||||
WHERE user_id NOT IN (SELECT user_id FROM user_preferences WHERE preference_key = 'notifications_enabled');
|
|
||||||
|
|
||||||
INSERT INTO user_preferences (user_id, preference_key, preference_value)
|
|
||||||
SELECT user_id, 'sound_effects', '1' FROM users
|
|
||||||
WHERE user_id NOT IN (SELECT user_id FROM user_preferences WHERE preference_key = 'sound_effects');
|
|
||||||
|
|
||||||
INSERT INTO user_preferences (user_id, preference_key, preference_value)
|
|
||||||
SELECT user_id, 'toast_duration', '3000' FROM users
|
|
||||||
WHERE user_id NOT IN (SELECT user_id FROM user_preferences WHERE preference_key = 'toast_duration');
|
|
||||||
|
|
||||||
-- Verify table created
|
|
||||||
SELECT 'User Preferences Table Created' as info;
|
|
||||||
DESCRIBE user_preferences;
|
|
||||||
|
|
||||||
-- Show count of preferences
|
|
||||||
SELECT 'Default Preferences Inserted' as info;
|
|
||||||
SELECT preference_key, COUNT(*) as user_count
|
|
||||||
FROM user_preferences
|
|
||||||
GROUP BY preference_key
|
|
||||||
ORDER BY preference_key;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- Remove all ticket view tracking records from audit_log
|
|
||||||
DELETE FROM audit_log WHERE action_type = 'view';
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
-- Create saved_filters table for storing user's custom search filters
|
|
||||||
CREATE TABLE IF NOT EXISTS saved_filters (
|
|
||||||
filter_id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
user_id INT NOT NULL,
|
|
||||||
filter_name VARCHAR(100) NOT NULL,
|
|
||||||
filter_criteria JSON NOT NULL,
|
|
||||||
is_default BOOLEAN DEFAULT FALSE,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
|
|
||||||
UNIQUE KEY unique_user_filter_name (user_id, filter_name)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- Create index for faster lookups
|
|
||||||
CREATE INDEX idx_user_filters ON saved_filters(user_id, is_default);
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
-- Migration 013: Add performance indexes for critical queries
|
|
||||||
|
|
||||||
-- Index on ticket_comments.ticket_id (foreign key without index)
|
|
||||||
-- Speeds up comment loading by 10-100x on large tables
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ticket_comments_ticket_id
|
|
||||||
ON ticket_comments(ticket_id);
|
|
||||||
|
|
||||||
-- Composite index on audit_log for entity lookups with date sorting
|
|
||||||
-- Optimizes activity timeline queries
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_entity_created
|
|
||||||
ON audit_log(entity_type, entity_id, created_at DESC);
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
-- Rollback for migration 013: Remove performance indexes
|
|
||||||
|
|
||||||
DROP INDEX IF EXISTS idx_ticket_comments_ticket_id ON ticket_comments;
|
|
||||||
DROP INDEX IF EXISTS idx_audit_entity_created ON audit_log;
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
# Migration 009: Simplify Status Workflow
|
|
||||||
|
|
||||||
This migration removes the "Resolved" status and adds a "Pending" status to the ticket system.
|
|
||||||
|
|
||||||
## Status Changes
|
|
||||||
|
|
||||||
### Before (4 statuses):
|
|
||||||
- Open
|
|
||||||
- In Progress
|
|
||||||
- **Resolved** ❌ (being removed)
|
|
||||||
- Closed
|
|
||||||
|
|
||||||
### After (4 statuses):
|
|
||||||
- Open
|
|
||||||
- **Pending** ✅ (new)
|
|
||||||
- In Progress
|
|
||||||
- Closed
|
|
||||||
|
|
||||||
## What "Pending" Means
|
|
||||||
|
|
||||||
**Pending** status indicates a ticket is waiting on:
|
|
||||||
- External dependencies
|
|
||||||
- Third-party responses
|
|
||||||
- Parts/equipment to arrive
|
|
||||||
- Customer information
|
|
||||||
- Approval from another team
|
|
||||||
|
|
||||||
Unlike "In Progress" which means active work is happening, "Pending" means the ticket is blocked and waiting.
|
|
||||||
|
|
||||||
## Running the Migration
|
|
||||||
|
|
||||||
On the tinkertickets server, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /var/www/html/tinkertickets/migrations
|
|
||||||
mysql -h 10.10.10.50 -u tinkertickets -p'&*woX!5R$x8Tyrm7zNxC' ticketing_system < 009_simplify_status_workflow.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
## What the Migration Does
|
|
||||||
|
|
||||||
1. Updates any existing tickets with status "Resolved" to "Closed"
|
|
||||||
2. Deletes all status transitions involving "Resolved"
|
|
||||||
3. Creates new workflow with "Pending" status
|
|
||||||
4. Sets up the following allowed transitions:
|
|
||||||
|
|
||||||
### New Workflow Transitions:
|
|
||||||
|
|
||||||
**From Open:**
|
|
||||||
- → Pending (no comment required)
|
|
||||||
- → In Progress (no comment required)
|
|
||||||
- → Closed (requires comment)
|
|
||||||
|
|
||||||
**From Pending:**
|
|
||||||
- → Open (no comment required)
|
|
||||||
- → In Progress (no comment required)
|
|
||||||
- → Closed (requires comment)
|
|
||||||
|
|
||||||
**From In Progress:**
|
|
||||||
- → Open (no comment required)
|
|
||||||
- → Pending (no comment required)
|
|
||||||
- → Closed (requires comment)
|
|
||||||
|
|
||||||
**From Closed:**
|
|
||||||
- → Open (requires comment - explain why reopening)
|
|
||||||
- → In Progress (no comment required)
|
|
||||||
|
|
||||||
## CSS Updates
|
|
||||||
|
|
||||||
The following CSS files have been updated:
|
|
||||||
- ✅ `/assets/css/dashboard.css` - Added `.status-Pending` styling with purple color (#9c27b0) and pause icon
|
|
||||||
- ✅ `/assets/css/ticket.css` - Added `.status-Pending` styling
|
|
||||||
|
|
||||||
## Visual Appearance
|
|
||||||
|
|
||||||
The Pending status will display as:
|
|
||||||
```
|
|
||||||
[⏸ PENDING]
|
|
||||||
```
|
|
||||||
- Purple color border and text
|
|
||||||
- Pause icon (⏸) to indicate waiting state
|
|
||||||
- Terminal-style glow effect
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
After running the migration, verify:
|
|
||||||
|
|
||||||
1. Check that all tickets previously marked "Resolved" are now "Closed":
|
|
||||||
```sql
|
|
||||||
SELECT COUNT(*) FROM tickets WHERE status = 'Resolved'; -- Should be 0
|
|
||||||
SELECT COUNT(*) FROM tickets WHERE status = 'Closed';
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Check new transitions exist:
|
|
||||||
```sql
|
|
||||||
SELECT from_status, to_status FROM status_transitions
|
|
||||||
WHERE from_status = 'Pending' OR to_status = 'Pending'
|
|
||||||
ORDER BY from_status, to_status;
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Test creating a new ticket and changing its status to Pending in the UI
|
|
||||||
|
|
||||||
## Rollback (if needed)
|
|
||||||
|
|
||||||
If you need to rollback this migration:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Restore Resolved status transitions
|
|
||||||
DELETE FROM status_transitions WHERE from_status = 'Pending' OR to_status = 'Pending';
|
|
||||||
|
|
||||||
INSERT INTO status_transitions (from_status, to_status, requires_comment) VALUES
|
|
||||||
('In Progress', 'Resolved', FALSE),
|
|
||||||
('Resolved', 'Closed', FALSE),
|
|
||||||
('Resolved', 'In Progress', TRUE),
|
|
||||||
('Open', 'Resolved', FALSE);
|
|
||||||
|
|
||||||
-- Update any Pending tickets to Open
|
|
||||||
UPDATE tickets SET status = 'Open' WHERE status = 'Pending';
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Database Migration Runner
|
||||||
|
*
|
||||||
|
* Runs SQL migration files in order. Tracks completed migrations
|
||||||
|
* to prevent re-running them.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php migrate.php # Run all pending migrations
|
||||||
|
* php migrate.php --status # Show migration status
|
||||||
|
* php migrate.php --dry-run # Show what would be run without executing
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Prevent web access
|
||||||
|
if (php_sapi_name() !== 'cli') {
|
||||||
|
http_response_code(403);
|
||||||
|
exit('CLI access only');
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/config/config.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
|
|
||||||
|
$dryRun = in_array('--dry-run', $argv);
|
||||||
|
$statusOnly = in_array('--status', $argv);
|
||||||
|
|
||||||
|
echo "=== Database Migration Runner ===\n\n";
|
||||||
|
|
||||||
|
try {
|
||||||
|
$conn = Database::getConnection();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo "Error: Could not connect to database: " . $e->getMessage() . "\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create migrations tracking table if it doesn't exist
|
||||||
|
$createTable = "CREATE TABLE IF NOT EXISTS migrations (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
filename VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_filename (filename)
|
||||||
|
)";
|
||||||
|
|
||||||
|
if (!$conn->query($createTable)) {
|
||||||
|
echo "Error: Could not create migrations table: " . $conn->error . "\n";
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get list of completed migrations
|
||||||
|
$completed = [];
|
||||||
|
$result = $conn->query("SELECT filename FROM migrations ORDER BY id");
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$completed[] = $row['filename'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get list of migration files
|
||||||
|
$migrationsDir = __DIR__;
|
||||||
|
$files = glob($migrationsDir . '/*.sql');
|
||||||
|
sort($files);
|
||||||
|
|
||||||
|
if (empty($files)) {
|
||||||
|
echo "No migration files found.\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($statusOnly) {
|
||||||
|
echo "Migration Status:\n";
|
||||||
|
echo str_repeat('-', 60) . "\n";
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$filename = basename($file);
|
||||||
|
$status = in_array($filename, $completed) ? '[DONE]' : '[PENDING]';
|
||||||
|
echo sprintf(" %s %s\n", $status, $filename);
|
||||||
|
}
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find pending migrations
|
||||||
|
$pending = [];
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$filename = basename($file);
|
||||||
|
if (!in_array($filename, $completed)) {
|
||||||
|
$pending[] = $file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($pending)) {
|
||||||
|
echo "All migrations are up to date.\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo sprintf("Found %d pending migration(s):\n", count($pending));
|
||||||
|
foreach ($pending as $file) {
|
||||||
|
echo " - " . basename($file) . "\n";
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
echo "[DRY RUN] No changes made.\n";
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run pending migrations
|
||||||
|
$success = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
foreach ($pending as $file) {
|
||||||
|
$filename = basename($file);
|
||||||
|
echo "Running: $filename... ";
|
||||||
|
|
||||||
|
$sql = file_get_contents($file);
|
||||||
|
if ($sql === false) {
|
||||||
|
echo "FAILED (could not read file)\n";
|
||||||
|
$failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute migration - handle multiple statements
|
||||||
|
$conn->begin_transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Split by semicolon but respect statements properly
|
||||||
|
// Note: This doesn't handle semicolons in strings, but our migrations are simple
|
||||||
|
$statements = array_filter(
|
||||||
|
array_map('trim', explode(';', $sql)),
|
||||||
|
function($stmt) {
|
||||||
|
// Remove comments and check if there's actual SQL
|
||||||
|
$cleaned = preg_replace('/--.*$/m', '', $stmt);
|
||||||
|
return !empty(trim($cleaned));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($statements as $statement) {
|
||||||
|
if (!$conn->query($statement)) {
|
||||||
|
// Some "errors" are acceptable (like "index already exists")
|
||||||
|
$error = $conn->error;
|
||||||
|
if (strpos($error, 'Duplicate key name') !== false ||
|
||||||
|
strpos($error, 'already exists') !== false) {
|
||||||
|
// Index already exists, that's fine
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Exception($error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the migration
|
||||||
|
$stmt = $conn->prepare("INSERT INTO migrations (filename) VALUES (?)");
|
||||||
|
$stmt->bind_param('s', $filename);
|
||||||
|
if (!$stmt->execute()) {
|
||||||
|
throw new Exception("Could not record migration: " . $conn->error);
|
||||||
|
}
|
||||||
|
|
||||||
|
$conn->commit();
|
||||||
|
echo "OK\n";
|
||||||
|
$success++;
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$conn->rollback();
|
||||||
|
echo "FAILED (" . $e->getMessage() . ")\n";
|
||||||
|
$failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n";
|
||||||
|
echo "=== Migration Complete ===\n";
|
||||||
|
echo sprintf(" Success: %d\n", $success);
|
||||||
|
echo sprintf(" Failed: %d\n", $failed);
|
||||||
|
|
||||||
|
exit($failed > 0 ? 1 : 0);
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
-- Rollback script to undo all SSO integration changes
|
|
||||||
-- WARNING: This will delete all user data, API keys, and audit logs
|
|
||||||
|
|
||||||
-- Drop foreign keys first
|
|
||||||
ALTER TABLE ticket_comments DROP FOREIGN KEY IF EXISTS fk_comments_user_id;
|
|
||||||
ALTER TABLE tickets DROP FOREIGN KEY IF EXISTS fk_tickets_created_by;
|
|
||||||
ALTER TABLE tickets DROP FOREIGN KEY IF EXISTS fk_tickets_updated_by;
|
|
||||||
ALTER TABLE api_keys DROP FOREIGN KEY IF EXISTS api_keys_ibfk_1;
|
|
||||||
ALTER TABLE audit_log DROP FOREIGN KEY IF EXISTS audit_log_ibfk_1;
|
|
||||||
|
|
||||||
-- Drop columns from existing tables
|
|
||||||
ALTER TABLE ticket_comments DROP COLUMN IF EXISTS user_id;
|
|
||||||
ALTER TABLE tickets DROP COLUMN IF EXISTS created_by;
|
|
||||||
ALTER TABLE tickets DROP COLUMN IF EXISTS updated_by;
|
|
||||||
ALTER TABLE tickets DROP COLUMN IF EXISTS updated_at;
|
|
||||||
|
|
||||||
-- Drop new tables
|
|
||||||
DROP TABLE IF EXISTS audit_log;
|
|
||||||
DROP TABLE IF EXISTS api_keys;
|
|
||||||
DROP TABLE IF EXISTS users;
|
|
||||||
|
|
||||||
-- Drop indexes
|
|
||||||
DROP INDEX IF EXISTS idx_status ON tickets;
|
|
||||||
DROP INDEX IF EXISTS idx_priority ON tickets;
|
|
||||||
DROP INDEX IF EXISTS idx_tickets_created_at ON tickets;
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* Database Migration Runner
|
|
||||||
* Executes all migration files in order
|
|
||||||
*/
|
|
||||||
|
|
||||||
error_reporting(E_ALL);
|
|
||||||
ini_set('display_errors', 1);
|
|
||||||
|
|
||||||
// Load environment variables
|
|
||||||
$envFile = dirname(__DIR__) . '/.env';
|
|
||||||
if (!file_exists($envFile)) {
|
|
||||||
die("Error: .env file not found at $envFile\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
$envVars = parse_ini_file($envFile);
|
|
||||||
if (!$envVars) {
|
|
||||||
die("Error: Could not parse .env file\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect to database
|
|
||||||
$conn = new mysqli(
|
|
||||||
$envVars['DB_HOST'],
|
|
||||||
$envVars['DB_USER'],
|
|
||||||
$envVars['DB_PASS'],
|
|
||||||
$envVars['DB_NAME']
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($conn->connect_error) {
|
|
||||||
die("Database connection failed: " . $conn->connect_error . "\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "Connected to database: {$envVars['DB_NAME']}\n\n";
|
|
||||||
|
|
||||||
// Get all migration files
|
|
||||||
$migrationFiles = glob(__DIR__ . '/*.sql');
|
|
||||||
sort($migrationFiles);
|
|
||||||
|
|
||||||
// Filter out rollback script
|
|
||||||
$migrationFiles = array_filter($migrationFiles, function($file) {
|
|
||||||
return !strpos($file, 'rollback');
|
|
||||||
});
|
|
||||||
|
|
||||||
if (empty($migrationFiles)) {
|
|
||||||
echo "No migration files found.\n";
|
|
||||||
exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "Found " . count($migrationFiles) . " migration(s):\n";
|
|
||||||
foreach ($migrationFiles as $file) {
|
|
||||||
echo " - " . basename($file) . "\n";
|
|
||||||
}
|
|
||||||
echo "\n";
|
|
||||||
|
|
||||||
// Execute each migration
|
|
||||||
$successCount = 0;
|
|
||||||
$errorCount = 0;
|
|
||||||
|
|
||||||
foreach ($migrationFiles as $file) {
|
|
||||||
$filename = basename($file);
|
|
||||||
echo "Executing: $filename... ";
|
|
||||||
|
|
||||||
$sql = file_get_contents($file);
|
|
||||||
|
|
||||||
// Split SQL into individual statements
|
|
||||||
// This handles multi-statement migrations
|
|
||||||
if ($conn->multi_query($sql)) {
|
|
||||||
do {
|
|
||||||
// Store first result set
|
|
||||||
if ($result = $conn->store_result()) {
|
|
||||||
$result->free();
|
|
||||||
}
|
|
||||||
// Check for errors
|
|
||||||
if ($conn->errno) {
|
|
||||||
echo "FAILED\n";
|
|
||||||
echo " Error: " . $conn->error . "\n";
|
|
||||||
$errorCount++;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} while ($conn->more_results() && $conn->next_result());
|
|
||||||
|
|
||||||
// If we got through all results without error
|
|
||||||
if (!$conn->errno) {
|
|
||||||
echo "OK\n";
|
|
||||||
$successCount++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
echo "FAILED\n";
|
|
||||||
echo " Error: " . $conn->error . "\n";
|
|
||||||
$errorCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "\n";
|
|
||||||
echo "Migration Summary:\n";
|
|
||||||
echo " Success: $successCount\n";
|
|
||||||
echo " Errors: $errorCount\n";
|
|
||||||
|
|
||||||
if ($errorCount > 0) {
|
|
||||||
echo "\nSome migrations failed. Please review errors above.\n";
|
|
||||||
exit(1);
|
|
||||||
} else {
|
|
||||||
echo "\nAll migrations completed successfully!\n";
|
|
||||||
exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
$conn->close();
|
|
||||||
+21
-10
@@ -1,11 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ApiKeyModel - Handles API key generation and validation
|
* ApiKeyModel - Handles API key generation and validation
|
||||||
*/
|
*/
|
||||||
class ApiKeyModel {
|
class ApiKeyModel
|
||||||
|
{
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,7 +20,8 @@ class ApiKeyModel {
|
|||||||
* @param int|null $expiresInDays Number of days until expiration (null for no expiration)
|
* @param int|null $expiresInDays Number of days until expiration (null for no expiration)
|
||||||
* @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'error'
|
* @return array Array with 'success', 'api_key' (plaintext), 'key_prefix', 'error'
|
||||||
*/
|
*/
|
||||||
public function createKey($keyName, $createdBy, $expiresInDays = null) {
|
public function createKey($keyName, $createdBy, $expiresInDays = null)
|
||||||
|
{
|
||||||
// Generate random API key (32 bytes = 64 hex characters)
|
// Generate random API key (32 bytes = 64 hex characters)
|
||||||
$apiKey = bin2hex(random_bytes(32));
|
$apiKey = bin2hex(random_bytes(32));
|
||||||
|
|
||||||
@@ -67,7 +71,8 @@ class ApiKeyModel {
|
|||||||
* @param string $apiKey Plaintext API key to validate
|
* @param string $apiKey Plaintext API key to validate
|
||||||
* @return array|null API key record if valid, null if invalid
|
* @return array|null API key record if valid, null if invalid
|
||||||
*/
|
*/
|
||||||
public function validateKey($apiKey) {
|
public function validateKey($apiKey)
|
||||||
|
{
|
||||||
if (empty($apiKey)) {
|
if (empty($apiKey)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -111,7 +116,8 @@ class ApiKeyModel {
|
|||||||
* @param int $keyId API key ID
|
* @param int $keyId API key ID
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
private function updateLastUsed($keyId) {
|
private function updateLastUsed($keyId)
|
||||||
|
{
|
||||||
$stmt = $this->conn->prepare("UPDATE api_keys SET last_used = NOW() WHERE api_key_id = ?");
|
$stmt = $this->conn->prepare("UPDATE api_keys SET last_used = NOW() WHERE api_key_id = ?");
|
||||||
$stmt->bind_param("i", $keyId);
|
$stmt->bind_param("i", $keyId);
|
||||||
$success = $stmt->execute();
|
$success = $stmt->execute();
|
||||||
@@ -125,7 +131,8 @@ class ApiKeyModel {
|
|||||||
* @param int $keyId API key ID
|
* @param int $keyId API key ID
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function revokeKey($keyId) {
|
public function revokeKey($keyId)
|
||||||
|
{
|
||||||
$stmt = $this->conn->prepare("UPDATE api_keys SET is_active = 0 WHERE api_key_id = ?");
|
$stmt = $this->conn->prepare("UPDATE api_keys SET is_active = 0 WHERE api_key_id = ?");
|
||||||
$stmt->bind_param("i", $keyId);
|
$stmt->bind_param("i", $keyId);
|
||||||
$success = $stmt->execute();
|
$success = $stmt->execute();
|
||||||
@@ -139,7 +146,8 @@ class ApiKeyModel {
|
|||||||
* @param int $keyId API key ID
|
* @param int $keyId API key ID
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function deleteKey($keyId) {
|
public function deleteKey($keyId)
|
||||||
|
{
|
||||||
$stmt = $this->conn->prepare("DELETE FROM api_keys WHERE api_key_id = ?");
|
$stmt = $this->conn->prepare("DELETE FROM api_keys WHERE api_key_id = ?");
|
||||||
$stmt->bind_param("i", $keyId);
|
$stmt->bind_param("i", $keyId);
|
||||||
$success = $stmt->execute();
|
$success = $stmt->execute();
|
||||||
@@ -152,7 +160,8 @@ class ApiKeyModel {
|
|||||||
*
|
*
|
||||||
* @return array Array of API key records (without hashes)
|
* @return array Array of API key records (without hashes)
|
||||||
*/
|
*/
|
||||||
public function getAllKeys() {
|
public function getAllKeys()
|
||||||
|
{
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
"SELECT ak.*, u.username, u.display_name
|
"SELECT ak.*, u.username, u.display_name
|
||||||
FROM api_keys ak
|
FROM api_keys ak
|
||||||
@@ -179,7 +188,8 @@ class ApiKeyModel {
|
|||||||
* @param int $keyId API key ID
|
* @param int $keyId API key ID
|
||||||
* @return array|null API key record (without hash) or null if not found
|
* @return array|null API key record (without hash) or null if not found
|
||||||
*/
|
*/
|
||||||
public function getKeyById($keyId) {
|
public function getKeyById($keyId)
|
||||||
|
{
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
"SELECT ak.*, u.username, u.display_name
|
"SELECT ak.*, u.username, u.display_name
|
||||||
FROM api_keys ak
|
FROM api_keys ak
|
||||||
@@ -208,7 +218,8 @@ class ApiKeyModel {
|
|||||||
* @param int $userId User ID
|
* @param int $userId User ID
|
||||||
* @return array Array of API key records
|
* @return array Array of API key records
|
||||||
*/
|
*/
|
||||||
public function getKeysByUser($userId) {
|
public function getKeysByUser($userId)
|
||||||
|
{
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
"SELECT * FROM api_keys WHERE created_by = ? ORDER BY created_at DESC"
|
"SELECT * FROM api_keys WHERE created_by = ? ORDER BY created_at DESC"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AttachmentModel - Handles ticket file attachments
|
||||||
|
*/
|
||||||
|
|
||||||
|
class AttachmentModel
|
||||||
|
{
|
||||||
|
private $conn;
|
||||||
|
|
||||||
|
public function __construct($conn)
|
||||||
|
{
|
||||||
|
$this->conn = $conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all attachments for a ticket
|
||||||
|
*/
|
||||||
|
public function getAttachments($ticketId)
|
||||||
|
{
|
||||||
|
$sql = "SELECT a.*, u.username, u.display_name
|
||||||
|
FROM ticket_attachments a
|
||||||
|
LEFT JOIN users u ON a.uploaded_by = u.user_id
|
||||||
|
WHERE a.ticket_id = ?
|
||||||
|
ORDER BY a.uploaded_at DESC";
|
||||||
|
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("s", $ticketId);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
$attachments = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$attachments[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->close();
|
||||||
|
return $attachments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single attachment by ID
|
||||||
|
*/
|
||||||
|
public function getAttachment($attachmentId)
|
||||||
|
{
|
||||||
|
$sql = "SELECT a.*, u.username, u.display_name
|
||||||
|
FROM ticket_attachments a
|
||||||
|
LEFT JOIN users u ON a.uploaded_by = u.user_id
|
||||||
|
WHERE a.attachment_id = ?";
|
||||||
|
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("i", $attachmentId);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
$attachment = $result->fetch_assoc();
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
return $attachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new attachment record
|
||||||
|
*/
|
||||||
|
public function addAttachment($ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy)
|
||||||
|
{
|
||||||
|
$sql = "INSERT INTO ticket_attachments (ticket_id, filename, original_filename, file_size, mime_type, uploaded_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)";
|
||||||
|
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("sssisi", $ticketId, $filename, $originalFilename, $fileSize, $mimeType, $uploadedBy);
|
||||||
|
$result = $stmt->execute();
|
||||||
|
|
||||||
|
if ($result) {
|
||||||
|
$attachmentId = $this->conn->insert_id;
|
||||||
|
$stmt->close();
|
||||||
|
return $attachmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an attachment record
|
||||||
|
*/
|
||||||
|
public function deleteAttachment($attachmentId)
|
||||||
|
{
|
||||||
|
$sql = "DELETE FROM ticket_attachments WHERE attachment_id = ?";
|
||||||
|
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("i", $attachmentId);
|
||||||
|
$result = $stmt->execute();
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total attachment size for a ticket
|
||||||
|
*/
|
||||||
|
public function getTotalSizeForTicket($ticketId)
|
||||||
|
{
|
||||||
|
$sql = "SELECT COALESCE(SUM(file_size), 0) as total_size
|
||||||
|
FROM ticket_attachments
|
||||||
|
WHERE ticket_id = ?";
|
||||||
|
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("s", $ticketId);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
$row = $result->fetch_assoc();
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
return (int)$row['total_size'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get attachment count for a ticket
|
||||||
|
*/
|
||||||
|
public function getAttachmentCount($ticketId)
|
||||||
|
{
|
||||||
|
$sql = "SELECT COUNT(*) as count FROM ticket_attachments WHERE ticket_id = ?";
|
||||||
|
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("s", $ticketId);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
$row = $result->fetch_assoc();
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
return (int)$row['count'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user can delete attachment (owner or admin)
|
||||||
|
*/
|
||||||
|
public function canUserDelete($attachmentId, $userId, $isAdmin = false)
|
||||||
|
{
|
||||||
|
if ($isAdmin) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attachment = $this->getAttachment($attachmentId);
|
||||||
|
return $attachment && (int)$attachment['uploaded_by'] === (int)$userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format file size for display
|
||||||
|
*/
|
||||||
|
public static function formatFileSize($bytes)
|
||||||
|
{
|
||||||
|
if ($bytes >= 1073741824) {
|
||||||
|
return number_format($bytes / 1073741824, 2) . ' GB';
|
||||||
|
} elseif ($bytes >= 1048576) {
|
||||||
|
return number_format($bytes / 1048576, 2) . ' MB';
|
||||||
|
} elseif ($bytes >= 1024) {
|
||||||
|
return number_format($bytes / 1024, 2) . ' KB';
|
||||||
|
} else {
|
||||||
|
return $bytes . ' bytes';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file icon based on mime type
|
||||||
|
*/
|
||||||
|
public static function getFileIcon($mimeType)
|
||||||
|
{
|
||||||
|
if (strpos($mimeType, 'image/') === 0) {
|
||||||
|
return '🖼️';
|
||||||
|
} elseif (strpos($mimeType, 'video/') === 0) {
|
||||||
|
return '🎬';
|
||||||
|
} elseif (strpos($mimeType, 'audio/') === 0) {
|
||||||
|
return '🎵';
|
||||||
|
} elseif ($mimeType === 'application/pdf') {
|
||||||
|
return '📄';
|
||||||
|
} elseif (strpos($mimeType, 'text/') === 0) {
|
||||||
|
return '📝';
|
||||||
|
} elseif (in_array($mimeType, ['application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed', 'application/gzip'])) {
|
||||||
|
return '📦';
|
||||||
|
} elseif (in_array($mimeType, ['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'])) {
|
||||||
|
return '📘';
|
||||||
|
} elseif (in_array($mimeType, ['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])) {
|
||||||
|
return '📊';
|
||||||
|
} else {
|
||||||
|
return '📎';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate file type against allowed types
|
||||||
|
*/
|
||||||
|
public static function isAllowedType($mimeType)
|
||||||
|
{
|
||||||
|
$allowedTypes = $GLOBALS['config']['ALLOWED_FILE_TYPES'] ?? [
|
||||||
|
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||||||
|
'application/pdf',
|
||||||
|
'text/plain', 'text/csv',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed',
|
||||||
|
'application/json', 'application/xml'
|
||||||
|
];
|
||||||
|
|
||||||
|
return in_array($mimeType, $allowedTypes);
|
||||||
|
}
|
||||||
|
}
|
||||||
+310
-48
@@ -1,14 +1,104 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AuditLogModel - Handles audit trail logging for all user actions
|
* AuditLogModel - Handles audit trail logging for all user actions
|
||||||
*/
|
*/
|
||||||
class AuditLogModel {
|
class AuditLogModel
|
||||||
|
{
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
/** @var int Maximum allowed limit for pagination */
|
||||||
|
private const MAX_LIMIT = 1000;
|
||||||
|
|
||||||
|
/** @var int Default limit for pagination */
|
||||||
|
private const DEFAULT_LIMIT = 100;
|
||||||
|
|
||||||
|
/** @var array Allowed action types for filtering */
|
||||||
|
private const VALID_ACTION_TYPES = [
|
||||||
|
'create', 'update', 'delete', 'view', 'security_event',
|
||||||
|
'login', 'logout', 'assign', 'comment', 'bulk_update'
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @var array Allowed entity types for filtering */
|
||||||
|
private const VALID_ENTITY_TYPES = [
|
||||||
|
'ticket', 'comment', 'user', 'api_key', 'security',
|
||||||
|
'template', 'attachment', 'group'
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and sanitize pagination limit
|
||||||
|
*
|
||||||
|
* @param int $limit Requested limit
|
||||||
|
* @return int Validated limit
|
||||||
|
*/
|
||||||
|
private function validateLimit(int $limit): int
|
||||||
|
{
|
||||||
|
if ($limit < 1) {
|
||||||
|
return self::DEFAULT_LIMIT;
|
||||||
|
}
|
||||||
|
return min($limit, self::MAX_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and sanitize pagination offset
|
||||||
|
*
|
||||||
|
* @param int $offset Requested offset
|
||||||
|
* @return int Validated offset (non-negative)
|
||||||
|
*/
|
||||||
|
private function validateOffset(int $offset): int
|
||||||
|
{
|
||||||
|
return max(0, $offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate date format (YYYY-MM-DD)
|
||||||
|
*
|
||||||
|
* @param string $date Date string
|
||||||
|
* @return string|null Validated date or null if invalid
|
||||||
|
*/
|
||||||
|
private function validateDate(string $date): ?string
|
||||||
|
{
|
||||||
|
// Check format
|
||||||
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's a valid date
|
||||||
|
$parts = explode('-', $date);
|
||||||
|
if (!checkdate((int)$parts[1], (int)$parts[2], (int)$parts[0])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate action type
|
||||||
|
*
|
||||||
|
* @param string $actionType Action type to validate
|
||||||
|
* @return bool True if valid
|
||||||
|
*/
|
||||||
|
private function isValidActionType(string $actionType): bool
|
||||||
|
{
|
||||||
|
return in_array($actionType, self::VALID_ACTION_TYPES, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate entity type
|
||||||
|
*
|
||||||
|
* @param string $entityType Entity type to validate
|
||||||
|
* @return bool True if valid
|
||||||
|
*/
|
||||||
|
private function isValidEntityType(string $entityType): bool
|
||||||
|
{
|
||||||
|
return in_array($entityType, self::VALID_ENTITY_TYPES, true);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log an action to the audit trail
|
* Log an action to the audit trail
|
||||||
*
|
*
|
||||||
@@ -20,7 +110,8 @@ class AuditLogModel {
|
|||||||
* @param string|null $ipAddress IP address of the user
|
* @param string|null $ipAddress IP address of the user
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function log($userId, $actionType, $entityType, $entityId = null, $details = null, $ipAddress = null) {
|
public function log($userId, $actionType, $entityType, $entityId = null, $details = null, $ipAddress = null)
|
||||||
|
{
|
||||||
// Convert details array to JSON
|
// Convert details array to JSON
|
||||||
$detailsJson = null;
|
$detailsJson = null;
|
||||||
if ($details !== null) {
|
if ($details !== null) {
|
||||||
@@ -52,7 +143,10 @@ class AuditLogModel {
|
|||||||
* @param int $limit Maximum number of logs to return
|
* @param int $limit Maximum number of logs to return
|
||||||
* @return array Array of audit log records
|
* @return array Array of audit log records
|
||||||
*/
|
*/
|
||||||
public function getLogsByEntity($entityType, $entityId, $limit = 100) {
|
public function getLogsByEntity($entityType, $entityId, $limit = 100)
|
||||||
|
{
|
||||||
|
$limit = $this->validateLimit((int)$limit);
|
||||||
|
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
"SELECT al.*, u.username, u.display_name
|
"SELECT al.*, u.username, u.display_name
|
||||||
FROM audit_log al
|
FROM audit_log al
|
||||||
@@ -85,7 +179,11 @@ class AuditLogModel {
|
|||||||
* @param int $limit Maximum number of logs to return
|
* @param int $limit Maximum number of logs to return
|
||||||
* @return array Array of audit log records
|
* @return array Array of audit log records
|
||||||
*/
|
*/
|
||||||
public function getLogsByUser($userId, $limit = 100) {
|
public function getLogsByUser($userId, $limit = 100)
|
||||||
|
{
|
||||||
|
$limit = $this->validateLimit((int)$limit);
|
||||||
|
$userId = max(0, (int)$userId);
|
||||||
|
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
"SELECT al.*, u.username, u.display_name
|
"SELECT al.*, u.username, u.display_name
|
||||||
FROM audit_log al
|
FROM audit_log al
|
||||||
@@ -118,7 +216,11 @@ class AuditLogModel {
|
|||||||
* @param int $offset Offset for pagination
|
* @param int $offset Offset for pagination
|
||||||
* @return array Array of audit log records
|
* @return array Array of audit log records
|
||||||
*/
|
*/
|
||||||
public function getRecentLogs($limit = 50, $offset = 0) {
|
public function getRecentLogs($limit = 50, $offset = 0)
|
||||||
|
{
|
||||||
|
$limit = $this->validateLimit((int)$limit);
|
||||||
|
$offset = $this->validateOffset((int)$offset);
|
||||||
|
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
"SELECT al.*, u.username, u.display_name
|
"SELECT al.*, u.username, u.display_name
|
||||||
FROM audit_log al
|
FROM audit_log al
|
||||||
@@ -150,7 +252,15 @@ class AuditLogModel {
|
|||||||
* @param int $limit Maximum number of logs to return
|
* @param int $limit Maximum number of logs to return
|
||||||
* @return array Array of audit log records
|
* @return array Array of audit log records
|
||||||
*/
|
*/
|
||||||
public function getLogsByAction($actionType, $limit = 100) {
|
public function getLogsByAction($actionType, $limit = 100)
|
||||||
|
{
|
||||||
|
$limit = $this->validateLimit((int)$limit);
|
||||||
|
|
||||||
|
// Validate action type to prevent unexpected queries
|
||||||
|
if (!$this->isValidActionType($actionType)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
"SELECT al.*, u.username, u.display_name
|
"SELECT al.*, u.username, u.display_name
|
||||||
FROM audit_log al
|
FROM audit_log al
|
||||||
@@ -181,7 +291,8 @@ class AuditLogModel {
|
|||||||
*
|
*
|
||||||
* @return int Total count
|
* @return int Total count
|
||||||
*/
|
*/
|
||||||
public function getTotalCount() {
|
public function getTotalCount()
|
||||||
|
{
|
||||||
$result = $this->conn->query("SELECT COUNT(*) as count FROM audit_log");
|
$result = $this->conn->query("SELECT COUNT(*) as count FROM audit_log");
|
||||||
$row = $result->fetch_assoc();
|
$row = $result->fetch_assoc();
|
||||||
return (int)$row['count'];
|
return (int)$row['count'];
|
||||||
@@ -193,7 +304,8 @@ class AuditLogModel {
|
|||||||
* @param int $daysToKeep Number of days of logs to keep
|
* @param int $daysToKeep Number of days of logs to keep
|
||||||
* @return int Number of deleted records
|
* @return int Number of deleted records
|
||||||
*/
|
*/
|
||||||
public function deleteOldLogs($daysToKeep = 90) {
|
public function deleteOldLogs($daysToKeep = 90)
|
||||||
|
{
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
"DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)"
|
"DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)"
|
||||||
);
|
);
|
||||||
@@ -210,7 +322,8 @@ class AuditLogModel {
|
|||||||
*
|
*
|
||||||
* @return string Client IP address
|
* @return string Client IP address
|
||||||
*/
|
*/
|
||||||
private function getClientIP() {
|
private function getClientIP()
|
||||||
|
{
|
||||||
$ipAddress = '';
|
$ipAddress = '';
|
||||||
|
|
||||||
// Check for proxy headers
|
// Check for proxy headers
|
||||||
@@ -239,7 +352,8 @@ class AuditLogModel {
|
|||||||
* @param array $ticketData Ticket data
|
* @param array $ticketData Ticket data
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function logTicketCreate($userId, $ticketId, $ticketData) {
|
public function logTicketCreate($userId, $ticketId, $ticketData)
|
||||||
|
{
|
||||||
return $this->log(
|
return $this->log(
|
||||||
$userId,
|
$userId,
|
||||||
'create',
|
'create',
|
||||||
@@ -257,7 +371,8 @@ class AuditLogModel {
|
|||||||
* @param array $changes Array of changed fields
|
* @param array $changes Array of changed fields
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function logTicketUpdate($userId, $ticketId, $changes) {
|
public function logTicketUpdate($userId, $ticketId, $changes)
|
||||||
|
{
|
||||||
return $this->log($userId, 'update', 'ticket', $ticketId, $changes);
|
return $this->log($userId, 'update', 'ticket', $ticketId, $changes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,10 +384,11 @@ class AuditLogModel {
|
|||||||
* @param string $ticketId Associated ticket ID
|
* @param string $ticketId Associated ticket ID
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function logCommentCreate($userId, $commentId, $ticketId) {
|
public function logCommentCreate($userId, $commentId, $ticketId)
|
||||||
|
{
|
||||||
return $this->log(
|
return $this->log(
|
||||||
$userId,
|
$userId,
|
||||||
'create',
|
'comment',
|
||||||
'comment',
|
'comment',
|
||||||
(string)$commentId,
|
(string)$commentId,
|
||||||
['ticket_id' => $ticketId]
|
['ticket_id' => $ticketId]
|
||||||
@@ -286,10 +402,124 @@ class AuditLogModel {
|
|||||||
* @param string $ticketId Ticket ID
|
* @param string $ticketId Ticket ID
|
||||||
* @return bool Success status
|
* @return bool Success status
|
||||||
*/
|
*/
|
||||||
public function logTicketView($userId, $ticketId) {
|
public function logTicketView($userId, $ticketId)
|
||||||
|
{
|
||||||
return $this->log($userId, 'view', 'ticket', $ticketId);
|
return $this->log($userId, 'view', 'ticket', $ticketId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Security Event Logging Methods
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a security event
|
||||||
|
*
|
||||||
|
* @param string $eventType Type of security event
|
||||||
|
* @param array $details Additional details
|
||||||
|
* @param int|null $userId User ID if known
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
public function logSecurityEvent($eventType, $details = [], $userId = null)
|
||||||
|
{
|
||||||
|
$details['event_type'] = $eventType;
|
||||||
|
$details['user_agent'] = $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown';
|
||||||
|
return $this->log($userId, 'security_event', 'security', null, $details);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a failed authentication attempt
|
||||||
|
*
|
||||||
|
* @param string $username Username attempted
|
||||||
|
* @param string $reason Reason for failure
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
public function logFailedAuth($username, $reason = 'Invalid credentials')
|
||||||
|
{
|
||||||
|
return $this->logSecurityEvent('failed_auth', [
|
||||||
|
'username' => $username,
|
||||||
|
'reason' => $reason
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a CSRF token failure
|
||||||
|
*
|
||||||
|
* @param string $endpoint The endpoint that was accessed
|
||||||
|
* @param int|null $userId User ID if session exists
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
public function logCsrfFailure($endpoint, $userId = null)
|
||||||
|
{
|
||||||
|
return $this->logSecurityEvent('csrf_failure', [
|
||||||
|
'endpoint' => $endpoint,
|
||||||
|
'method' => $_SERVER['REQUEST_METHOD'] ?? 'Unknown'
|
||||||
|
], $userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a rate limit exceeded event
|
||||||
|
*
|
||||||
|
* @param string $endpoint The endpoint that was rate limited
|
||||||
|
* @param int|null $userId User ID if session exists
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
public function logRateLimitExceeded($endpoint, $userId = null)
|
||||||
|
{
|
||||||
|
return $this->logSecurityEvent('rate_limit_exceeded', [
|
||||||
|
'endpoint' => $endpoint
|
||||||
|
], $userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an unauthorized access attempt
|
||||||
|
*
|
||||||
|
* @param string $resource The resource that was accessed
|
||||||
|
* @param int|null $userId User ID if session exists
|
||||||
|
* @return bool Success status
|
||||||
|
*/
|
||||||
|
public function logUnauthorizedAccess($resource, $userId = null)
|
||||||
|
{
|
||||||
|
return $this->logSecurityEvent('unauthorized_access', [
|
||||||
|
'resource' => $resource
|
||||||
|
], $userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get security events (for admin review)
|
||||||
|
*
|
||||||
|
* @param int $limit Maximum number of events
|
||||||
|
* @param int $offset Offset for pagination
|
||||||
|
* @return array Security events
|
||||||
|
*/
|
||||||
|
public function getSecurityEvents($limit = 100, $offset = 0)
|
||||||
|
{
|
||||||
|
$limit = $this->validateLimit((int)$limit);
|
||||||
|
$offset = $this->validateOffset((int)$offset);
|
||||||
|
|
||||||
|
$stmt = $this->conn->prepare(
|
||||||
|
"SELECT al.*, u.username, u.display_name
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.user_id
|
||||||
|
WHERE al.action_type = 'security_event'
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT ? OFFSET ?"
|
||||||
|
);
|
||||||
|
$stmt->bind_param("ii", $limit, $offset);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
$events = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
if ($row['details']) {
|
||||||
|
$row['details'] = json_decode($row['details'], true);
|
||||||
|
}
|
||||||
|
$events[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt->close();
|
||||||
|
return $events;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get formatted timeline for a specific ticket
|
* Get formatted timeline for a specific ticket
|
||||||
* Includes all ticket updates and comments
|
* Includes all ticket updates and comments
|
||||||
@@ -297,7 +527,8 @@ class AuditLogModel {
|
|||||||
* @param string $ticketId Ticket ID
|
* @param string $ticketId Ticket ID
|
||||||
* @return array Timeline events
|
* @return array Timeline events
|
||||||
*/
|
*/
|
||||||
public function getTicketTimeline($ticketId) {
|
public function getTicketTimeline($ticketId)
|
||||||
|
{
|
||||||
$stmt = $this->conn->prepare(
|
$stmt = $this->conn->prepare(
|
||||||
"SELECT al.*, u.username, u.display_name
|
"SELECT al.*, u.username, u.display_name
|
||||||
FROM audit_log al
|
FROM audit_log al
|
||||||
@@ -330,60 +561,91 @@ class AuditLogModel {
|
|||||||
* @param int $offset Offset for pagination
|
* @param int $offset Offset for pagination
|
||||||
* @return array Array containing logs and total count
|
* @return array Array containing logs and total count
|
||||||
*/
|
*/
|
||||||
public function getFilteredLogs($filters = [], $limit = 50, $offset = 0) {
|
public function getFilteredLogs($filters = [], $limit = 50, $offset = 0)
|
||||||
|
{
|
||||||
|
// Validate pagination parameters
|
||||||
|
$limit = $this->validateLimit((int)$limit);
|
||||||
|
$offset = $this->validateOffset((int)$offset);
|
||||||
|
|
||||||
$whereConditions = [];
|
$whereConditions = [];
|
||||||
$params = [];
|
$params = [];
|
||||||
$paramTypes = '';
|
$paramTypes = '';
|
||||||
|
|
||||||
// Action type filter
|
// Action type filter - validate each action type
|
||||||
if (!empty($filters['action_type'])) {
|
if (!empty($filters['action_type'])) {
|
||||||
$actions = explode(',', $filters['action_type']);
|
$actions = array_filter(
|
||||||
$placeholders = str_repeat('?,', count($actions) - 1) . '?';
|
array_map('trim', explode(',', $filters['action_type'])),
|
||||||
$whereConditions[] = "al.action_type IN ($placeholders)";
|
fn($action) => $this->isValidActionType($action)
|
||||||
$params = array_merge($params, $actions);
|
);
|
||||||
$paramTypes .= str_repeat('s', count($actions));
|
if (!empty($actions)) {
|
||||||
|
$placeholders = str_repeat('?,', count($actions) - 1) . '?';
|
||||||
|
$whereConditions[] = "al.action_type IN ($placeholders)";
|
||||||
|
$params = array_merge($params, array_values($actions));
|
||||||
|
$paramTypes .= str_repeat('s', count($actions));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entity type filter
|
// Entity type filter - validate each entity type
|
||||||
if (!empty($filters['entity_type'])) {
|
if (!empty($filters['entity_type'])) {
|
||||||
$entities = explode(',', $filters['entity_type']);
|
$entities = array_filter(
|
||||||
$placeholders = str_repeat('?,', count($entities) - 1) . '?';
|
array_map('trim', explode(',', $filters['entity_type'])),
|
||||||
$whereConditions[] = "al.entity_type IN ($placeholders)";
|
fn($entity) => $this->isValidEntityType($entity)
|
||||||
$params = array_merge($params, $entities);
|
);
|
||||||
$paramTypes .= str_repeat('s', count($entities));
|
if (!empty($entities)) {
|
||||||
|
$placeholders = str_repeat('?,', count($entities) - 1) . '?';
|
||||||
|
$whereConditions[] = "al.entity_type IN ($placeholders)";
|
||||||
|
$params = array_merge($params, array_values($entities));
|
||||||
|
$paramTypes .= str_repeat('s', count($entities));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// User filter
|
// User filter - validate as positive integer
|
||||||
if (!empty($filters['user_id'])) {
|
if (!empty($filters['user_id'])) {
|
||||||
$whereConditions[] = "al.user_id = ?";
|
$userId = (int)$filters['user_id'];
|
||||||
$params[] = (int)$filters['user_id'];
|
if ($userId > 0) {
|
||||||
$paramTypes .= 'i';
|
$whereConditions[] = "al.user_id = ?";
|
||||||
|
$params[] = $userId;
|
||||||
|
$paramTypes .= 'i';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entity ID filter (for specific ticket/comment)
|
// Entity ID filter - sanitize (alphanumeric and dashes only)
|
||||||
if (!empty($filters['entity_id'])) {
|
if (!empty($filters['entity_id'])) {
|
||||||
$whereConditions[] = "al.entity_id = ?";
|
$entityId = preg_replace('/[^a-zA-Z0-9_-]/', '', $filters['entity_id']);
|
||||||
$params[] = $filters['entity_id'];
|
if (!empty($entityId)) {
|
||||||
$paramTypes .= 's';
|
$whereConditions[] = "al.entity_id = ?";
|
||||||
|
$params[] = $entityId;
|
||||||
|
$paramTypes .= 's';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Date range filters
|
// Date range filters - validate format
|
||||||
if (!empty($filters['date_from'])) {
|
if (!empty($filters['date_from'])) {
|
||||||
$whereConditions[] = "DATE(al.created_at) >= ?";
|
$dateFrom = $this->validateDate($filters['date_from']);
|
||||||
$params[] = $filters['date_from'];
|
if ($dateFrom !== null) {
|
||||||
$paramTypes .= 's';
|
$whereConditions[] = "DATE(al.created_at) >= ?";
|
||||||
|
$params[] = $dateFrom;
|
||||||
|
$paramTypes .= 's';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!empty($filters['date_to'])) {
|
if (!empty($filters['date_to'])) {
|
||||||
$whereConditions[] = "DATE(al.created_at) <= ?";
|
$dateTo = $this->validateDate($filters['date_to']);
|
||||||
$params[] = $filters['date_to'];
|
if ($dateTo !== null) {
|
||||||
$paramTypes .= 's';
|
$whereConditions[] = "DATE(al.created_at) <= ?";
|
||||||
|
$params[] = $dateTo;
|
||||||
|
$paramTypes .= 's';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// IP address filter
|
// IP address filter - validate format (basic IP pattern)
|
||||||
if (!empty($filters['ip_address'])) {
|
if (!empty($filters['ip_address'])) {
|
||||||
$whereConditions[] = "al.ip_address LIKE ?";
|
// Allow partial IP matching but sanitize input
|
||||||
$params[] = '%' . $filters['ip_address'] . '%';
|
$ipAddress = preg_replace('/[^0-9.:a-fA-F]/', '', $filters['ip_address']);
|
||||||
$paramTypes .= 's';
|
if (!empty($ipAddress) && strlen($ipAddress) <= 45) { // Max IPv6 length
|
||||||
|
$whereConditions[] = "al.ip_address LIKE ?";
|
||||||
|
$params[] = '%' . $ipAddress . '%';
|
||||||
|
$paramTypes .= 's';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build WHERE clause
|
// Build WHERE clause
|
||||||
|
|||||||
+165
-62
@@ -1,11 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BulkOperationsModel - Handles bulk ticket operations (Admin only)
|
* BulkOperationsModel - Handles bulk ticket operations (Admin only)
|
||||||
*/
|
*/
|
||||||
class BulkOperationsModel {
|
class BulkOperationsModel
|
||||||
|
{
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,7 +21,16 @@ class BulkOperationsModel {
|
|||||||
* @param array|null $parameters Operation parameters
|
* @param array|null $parameters Operation parameters
|
||||||
* @return int|false Operation ID or false on failure
|
* @return int|false Operation ID or false on failure
|
||||||
*/
|
*/
|
||||||
public function createBulkOperation($type, $ticketIds, $userId, $parameters = null) {
|
public function createBulkOperation($type, $ticketIds, $userId, $parameters = null)
|
||||||
|
{
|
||||||
|
// Validate ticket IDs to prevent injection via implode
|
||||||
|
$ticketIds = array_values(array_filter(
|
||||||
|
array_map('strval', $ticketIds),
|
||||||
|
fn($id) => preg_match('/^[0-9]+$/', $id)
|
||||||
|
));
|
||||||
|
if (empty($ticketIds)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
$ticketIdsStr = implode(',', $ticketIds);
|
$ticketIdsStr = implode(',', $ticketIds);
|
||||||
$totalTickets = count($ticketIds);
|
$totalTickets = count($ticketIds);
|
||||||
$parametersJson = $parameters ? json_encode($parameters) : null;
|
$parametersJson = $parameters ? json_encode($parameters) : null;
|
||||||
@@ -41,10 +53,15 @@ class BulkOperationsModel {
|
|||||||
/**
|
/**
|
||||||
* Process a bulk operation
|
* Process a bulk operation
|
||||||
*
|
*
|
||||||
|
* Uses database transaction to ensure atomicity - either all tickets
|
||||||
|
* are updated or none are (on failure, changes are rolled back).
|
||||||
|
*
|
||||||
* @param int $operationId Operation ID
|
* @param int $operationId Operation ID
|
||||||
|
* @param bool $atomic If true, rollback all changes on any failure
|
||||||
* @return array Result with processed and failed counts
|
* @return array Result with processed and failed counts
|
||||||
*/
|
*/
|
||||||
public function processBulkOperation($operationId) {
|
public function processBulkOperation($operationId, bool $atomic = false)
|
||||||
|
{
|
||||||
// Get operation details
|
// Get operation details
|
||||||
$sql = "SELECT * FROM bulk_operations WHERE operation_id = ?";
|
$sql = "SELECT * FROM bulk_operations WHERE operation_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
@@ -62,6 +79,7 @@ class BulkOperationsModel {
|
|||||||
$parameters = $operation['parameters'] ? json_decode($operation['parameters'], true) : [];
|
$parameters = $operation['parameters'] ? json_decode($operation['parameters'], true) : [];
|
||||||
$processed = 0;
|
$processed = 0;
|
||||||
$failed = 0;
|
$failed = 0;
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
// Load required models
|
// Load required models
|
||||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
@@ -73,17 +91,21 @@ class BulkOperationsModel {
|
|||||||
// Batch load all tickets in one query to eliminate N+1 problem
|
// Batch load all tickets in one query to eliminate N+1 problem
|
||||||
$ticketsById = $ticketModel->getTicketsByIds($ticketIds);
|
$ticketsById = $ticketModel->getTicketsByIds($ticketIds);
|
||||||
|
|
||||||
foreach ($ticketIds as $ticketId) {
|
// Start transaction for data consistency
|
||||||
$ticketId = trim($ticketId);
|
$this->conn->begin_transaction();
|
||||||
$success = false;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch ($operation['operation_type']) {
|
foreach ($ticketIds as $ticketId) {
|
||||||
case 'bulk_close':
|
$ticketId = trim($ticketId);
|
||||||
// Get current ticket from pre-loaded batch
|
$success = false;
|
||||||
$currentTicket = $ticketsById[$ticketId] ?? null;
|
|
||||||
if ($currentTicket) {
|
try {
|
||||||
$success = $ticketModel->updateTicket([
|
switch ($operation['operation_type']) {
|
||||||
|
case 'bulk_close':
|
||||||
|
// Get current ticket from pre-loaded batch
|
||||||
|
$currentTicket = $ticketsById[$ticketId] ?? null;
|
||||||
|
if ($currentTicket) {
|
||||||
|
$updateResult = $ticketModel->updateTicket([
|
||||||
'ticket_id' => $ticketId,
|
'ticket_id' => $ticketId,
|
||||||
'title' => $currentTicket['title'],
|
'title' => $currentTicket['title'],
|
||||||
'description' => $currentTicket['description'],
|
'description' => $currentTicket['description'],
|
||||||
@@ -91,30 +113,41 @@ class BulkOperationsModel {
|
|||||||
'type' => $currentTicket['type'],
|
'type' => $currentTicket['type'],
|
||||||
'status' => 'Closed',
|
'status' => 'Closed',
|
||||||
'priority' => $currentTicket['priority']
|
'priority' => $currentTicket['priority']
|
||||||
], $operation['performed_by']);
|
], $operation['performed_by']);
|
||||||
|
$success = $updateResult['success'];
|
||||||
|
|
||||||
if ($success) {
|
if ($success) {
|
||||||
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
|
$auditLogModel->log(
|
||||||
['status' => 'Closed', 'bulk_operation_id' => $operationId]);
|
$operation['performed_by'],
|
||||||
|
'update',
|
||||||
|
'ticket',
|
||||||
|
$ticketId,
|
||||||
|
['status' => 'Closed', 'bulk_operation_id' => $operationId]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
break;
|
||||||
break;
|
|
||||||
|
|
||||||
case 'bulk_assign':
|
case 'bulk_assign':
|
||||||
if (isset($parameters['assigned_to'])) {
|
if (isset($parameters['assigned_to'])) {
|
||||||
$success = $ticketModel->assignTicket($ticketId, $parameters['assigned_to'], $operation['performed_by']);
|
$success = $ticketModel->assignTicket($ticketId, $parameters['assigned_to'], $operation['performed_by']);
|
||||||
if ($success) {
|
if ($success) {
|
||||||
$auditLogModel->log($operation['performed_by'], 'assign', 'ticket', $ticketId,
|
$auditLogModel->log(
|
||||||
['assigned_to' => $parameters['assigned_to'], 'bulk_operation_id' => $operationId]);
|
$operation['performed_by'],
|
||||||
|
'assign',
|
||||||
|
'ticket',
|
||||||
|
$ticketId,
|
||||||
|
['assigned_to' => $parameters['assigned_to'], 'bulk_operation_id' => $operationId]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
break;
|
||||||
break;
|
|
||||||
|
|
||||||
case 'bulk_priority':
|
case 'bulk_priority':
|
||||||
if (isset($parameters['priority'])) {
|
if (isset($parameters['priority'])) {
|
||||||
$currentTicket = $ticketsById[$ticketId] ?? null;
|
$currentTicket = $ticketsById[$ticketId] ?? null;
|
||||||
if ($currentTicket) {
|
if ($currentTicket) {
|
||||||
$success = $ticketModel->updateTicket([
|
$updateResult = $ticketModel->updateTicket([
|
||||||
'ticket_id' => $ticketId,
|
'ticket_id' => $ticketId,
|
||||||
'title' => $currentTicket['title'],
|
'title' => $currentTicket['title'],
|
||||||
'description' => $currentTicket['description'],
|
'description' => $currentTicket['description'],
|
||||||
@@ -122,21 +155,27 @@ class BulkOperationsModel {
|
|||||||
'type' => $currentTicket['type'],
|
'type' => $currentTicket['type'],
|
||||||
'status' => $currentTicket['status'],
|
'status' => $currentTicket['status'],
|
||||||
'priority' => $parameters['priority']
|
'priority' => $parameters['priority']
|
||||||
], $operation['performed_by']);
|
], $operation['performed_by']);
|
||||||
|
$success = $updateResult['success'];
|
||||||
|
|
||||||
if ($success) {
|
if ($success) {
|
||||||
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
|
$auditLogModel->log(
|
||||||
['priority' => $parameters['priority'], 'bulk_operation_id' => $operationId]);
|
$operation['performed_by'],
|
||||||
|
'update',
|
||||||
|
'ticket',
|
||||||
|
$ticketId,
|
||||||
|
['priority' => $parameters['priority'], 'bulk_operation_id' => $operationId]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
break;
|
||||||
break;
|
|
||||||
|
|
||||||
case 'bulk_status':
|
case 'bulk_status':
|
||||||
if (isset($parameters['status'])) {
|
if (isset($parameters['status'])) {
|
||||||
$currentTicket = $ticketsById[$ticketId] ?? null;
|
$currentTicket = $ticketsById[$ticketId] ?? null;
|
||||||
if ($currentTicket) {
|
if ($currentTicket) {
|
||||||
$success = $ticketModel->updateTicket([
|
$updateResult = $ticketModel->updateTicket([
|
||||||
'ticket_id' => $ticketId,
|
'ticket_id' => $ticketId,
|
||||||
'title' => $currentTicket['title'],
|
'title' => $currentTicket['title'],
|
||||||
'description' => $currentTicket['description'],
|
'description' => $currentTicket['description'],
|
||||||
@@ -144,37 +183,99 @@ class BulkOperationsModel {
|
|||||||
'type' => $currentTicket['type'],
|
'type' => $currentTicket['type'],
|
||||||
'status' => $parameters['status'],
|
'status' => $parameters['status'],
|
||||||
'priority' => $currentTicket['priority']
|
'priority' => $currentTicket['priority']
|
||||||
], $operation['performed_by']);
|
], $operation['performed_by']);
|
||||||
|
$success = $updateResult['success'];
|
||||||
|
|
||||||
if ($success) {
|
if ($success) {
|
||||||
$auditLogModel->log($operation['performed_by'], 'update', 'ticket', $ticketId,
|
$auditLogModel->log(
|
||||||
['status' => $parameters['status'], 'bulk_operation_id' => $operationId]);
|
$operation['performed_by'],
|
||||||
|
'update',
|
||||||
|
'ticket',
|
||||||
|
$ticketId,
|
||||||
|
['status' => $parameters['status'], 'bulk_operation_id' => $operationId]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
break;
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($success) {
|
case 'bulk_delete':
|
||||||
$processed++;
|
$success = $ticketModel->deleteTicket($ticketId);
|
||||||
} else {
|
if ($success) {
|
||||||
|
$auditLogModel->log(
|
||||||
|
$operation['performed_by'],
|
||||||
|
'delete',
|
||||||
|
'ticket',
|
||||||
|
$ticketId,
|
||||||
|
['bulk_operation_id' => $operationId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($success) {
|
||||||
|
$processed++;
|
||||||
|
} else {
|
||||||
|
$failed++;
|
||||||
|
$errors[] = "Ticket $ticketId: Update failed";
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
$failed++;
|
$failed++;
|
||||||
|
$errors[] = "Ticket $ticketId: " . $e->getMessage();
|
||||||
|
error_log("Bulk operation error for ticket $ticketId: " . $e->getMessage());
|
||||||
}
|
}
|
||||||
} catch (Exception $e) {
|
|
||||||
$failed++;
|
|
||||||
error_log("Bulk operation error for ticket $ticketId: " . $e->getMessage());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If atomic mode and any failures, rollback everything
|
||||||
|
if ($atomic && $failed > 0) {
|
||||||
|
$this->conn->rollback();
|
||||||
|
error_log("Bulk operation $operationId rolled back due to $failed failures");
|
||||||
|
|
||||||
|
// Update operation status as failed
|
||||||
|
$sql = "UPDATE bulk_operations SET status = 'failed', processed_tickets = 0, failed_tickets = ?,
|
||||||
|
completed_at = NOW() WHERE operation_id = ?";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("ii", $failed, $operationId);
|
||||||
|
$stmt->execute();
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'processed' => 0,
|
||||||
|
'failed' => $failed,
|
||||||
|
'rolled_back' => true,
|
||||||
|
'errors' => $errors
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit the transaction
|
||||||
|
$this->conn->commit();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// Rollback on any unexpected error
|
||||||
|
$this->conn->rollback();
|
||||||
|
error_log("Bulk operation $operationId failed with exception: " . $e->getMessage());
|
||||||
|
|
||||||
|
return [
|
||||||
|
'processed' => 0,
|
||||||
|
'failed' => count($ticketIds),
|
||||||
|
'error' => 'Transaction failed: ' . $e->getMessage(),
|
||||||
|
'rolled_back' => true
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update operation status
|
// Update operation status
|
||||||
$sql = "UPDATE bulk_operations SET status = 'completed', processed_tickets = ?, failed_tickets = ?,
|
$status = $failed > 0 ? 'completed_with_errors' : 'completed';
|
||||||
|
$sql = "UPDATE bulk_operations SET status = ?, processed_tickets = ?, failed_tickets = ?,
|
||||||
completed_at = NOW() WHERE operation_id = ?";
|
completed_at = NOW() WHERE operation_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("iii", $processed, $failed, $operationId);
|
$stmt->bind_param("siii", $status, $processed, $failed, $operationId);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$stmt->close();
|
$stmt->close();
|
||||||
|
|
||||||
return ['processed' => $processed, 'failed' => $failed];
|
$result = ['processed' => $processed, 'failed' => $failed];
|
||||||
|
if (!empty($errors)) {
|
||||||
|
$result['errors'] = $errors;
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -183,7 +284,8 @@ class BulkOperationsModel {
|
|||||||
* @param int $operationId Operation ID
|
* @param int $operationId Operation ID
|
||||||
* @return array|null Operation record or null
|
* @return array|null Operation record or null
|
||||||
*/
|
*/
|
||||||
public function getOperationById($operationId) {
|
public function getOperationById($operationId)
|
||||||
|
{
|
||||||
$sql = "SELECT * FROM bulk_operations WHERE operation_id = ?";
|
$sql = "SELECT * FROM bulk_operations WHERE operation_id = ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("i", $operationId);
|
$stmt->bind_param("i", $operationId);
|
||||||
@@ -201,7 +303,8 @@ class BulkOperationsModel {
|
|||||||
* @param int $limit Result limit
|
* @param int $limit Result limit
|
||||||
* @return array Array of operations
|
* @return array Array of operations
|
||||||
*/
|
*/
|
||||||
public function getOperationsByUser($userId, $limit = 50) {
|
public function getOperationsByUser($userId, $limit = 50)
|
||||||
|
{
|
||||||
$sql = "SELECT * FROM bulk_operations WHERE performed_by = ?
|
$sql = "SELECT * FROM bulk_operations WHERE performed_by = ?
|
||||||
ORDER BY created_at DESC LIMIT ?";
|
ORDER BY created_at DESC LIMIT ?";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
|||||||
+377
-32
@@ -1,57 +1,304 @@
|
|||||||
<?php
|
<?php
|
||||||
class CommentModel {
|
|
||||||
|
class CommentModel
|
||||||
|
{
|
||||||
private $conn;
|
private $conn;
|
||||||
|
|
||||||
public function __construct($conn) {
|
public function __construct($conn)
|
||||||
|
{
|
||||||
$this->conn = $conn;
|
$this->conn = $conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getCommentsByTicketId($ticketId) {
|
/**
|
||||||
$sql = "SELECT tc.*, u.display_name, u.username
|
* Extract @mentions from comment text
|
||||||
FROM ticket_comments tc
|
*
|
||||||
LEFT JOIN users u ON tc.user_id = u.user_id
|
* @param string $text Comment text
|
||||||
WHERE tc.ticket_id = ?
|
* @return array Array of mentioned usernames
|
||||||
ORDER BY tc.created_at DESC";
|
*/
|
||||||
|
public function extractMentions($text)
|
||||||
|
{
|
||||||
|
$mentions = [];
|
||||||
|
// Match @username patterns (alphanumeric, underscores, hyphens)
|
||||||
|
if (preg_match_all('/@([a-zA-Z0-9_-]+)/', $text, $matches)) {
|
||||||
|
$mentions = array_unique($matches[1]);
|
||||||
|
}
|
||||||
|
return $mentions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user IDs for mentioned usernames
|
||||||
|
*
|
||||||
|
* @param array $usernames Array of usernames
|
||||||
|
* @return array Array of user records with user_id, username, display_name
|
||||||
|
*/
|
||||||
|
public function getMentionedUsers($usernames)
|
||||||
|
{
|
||||||
|
if (empty($usernames)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$placeholders = str_repeat('?,', count($usernames) - 1) . '?';
|
||||||
|
$sql = "SELECT user_id, username, display_name FROM users WHERE username IN ($placeholders)";
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
$stmt->bind_param("s", $ticketId); // Changed to string since ticket_id is varchar
|
|
||||||
|
$types = str_repeat('s', count($usernames));
|
||||||
|
$stmt->bind_param($types, ...$usernames);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
$comments = [];
|
$users = [];
|
||||||
|
while ($row = $result->fetch_assoc()) {
|
||||||
|
$users[] = $row;
|
||||||
|
}
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
return $users;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get total comment count for a ticket
|
||||||
|
*/
|
||||||
|
public function getCommentCount(int $ticketId): int
|
||||||
|
{
|
||||||
|
$stmt = $this->conn->prepare(
|
||||||
|
"SELECT COUNT(*) as total FROM ticket_comments WHERE ticket_id = ?"
|
||||||
|
);
|
||||||
|
$stmt->bind_param("i", $ticketId);
|
||||||
|
$stmt->execute();
|
||||||
|
$row = $stmt->get_result()->fetch_assoc();
|
||||||
|
$stmt->close();
|
||||||
|
return (int)($row['total'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $ticketId
|
||||||
|
* @param bool $threaded Build nested reply structure (threading)
|
||||||
|
* @param int $limit Max root-level comments to return (0 = all)
|
||||||
|
* @param int $offset Root-level comment offset for pagination
|
||||||
|
*/
|
||||||
|
public function getCommentsByTicketId($ticketId, $threaded = true, int $limit = 0, int $offset = 0)
|
||||||
|
{
|
||||||
|
$hasThreading = $this->hasThreadingSupport();
|
||||||
|
|
||||||
|
// When paginating with threading we fetch root comments page first,
|
||||||
|
// then pull all their replies in a second query.
|
||||||
|
if ($hasThreading && $threaded && $limit > 0) {
|
||||||
|
return $this->getThreadedCommentsPaged($ticketId, $limit, $offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasThreading) {
|
||||||
|
$sql = "SELECT tc.*, u.display_name, u.username, tc.parent_comment_id, tc.thread_depth
|
||||||
|
FROM ticket_comments tc
|
||||||
|
LEFT JOIN users u ON tc.user_id = u.user_id
|
||||||
|
WHERE tc.ticket_id = ?
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN tc.parent_comment_id IS NULL THEN tc.created_at END DESC,
|
||||||
|
CASE WHEN tc.parent_comment_id IS NOT NULL THEN tc.created_at END ASC";
|
||||||
|
} else {
|
||||||
|
$sql = "SELECT tc.*, u.display_name, u.username
|
||||||
|
FROM ticket_comments tc
|
||||||
|
LEFT JOIN users u ON tc.user_id = u.user_id
|
||||||
|
WHERE tc.ticket_id = ?
|
||||||
|
ORDER BY tc.created_at DESC";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($limit > 0) {
|
||||||
|
$sql .= " LIMIT ? OFFSET ?";
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
if ($limit > 0) {
|
||||||
|
$stmt->bind_param("iii", $ticketId, $limit, $offset);
|
||||||
|
} else {
|
||||||
|
$stmt->bind_param("i", $ticketId);
|
||||||
|
}
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
|
$commentMap = [];
|
||||||
while ($row = $result->fetch_assoc()) {
|
while ($row = $result->fetch_assoc()) {
|
||||||
// Use display_name from users table if available, fallback to user_name field
|
|
||||||
if (!empty($row['display_name'])) {
|
if (!empty($row['display_name'])) {
|
||||||
$row['display_name_formatted'] = $row['display_name'];
|
$row['display_name_formatted'] = $row['display_name'];
|
||||||
} else {
|
} else {
|
||||||
$row['display_name_formatted'] = $row['user_name'] ?? 'Unknown User';
|
$row['display_name_formatted'] = $row['user_name'] ?? 'Unknown User';
|
||||||
}
|
}
|
||||||
$comments[] = $row;
|
$row['replies'] = [];
|
||||||
|
$row['parent_comment_id'] = $row['parent_comment_id'] ?? null;
|
||||||
|
$row['thread_depth'] = $row['thread_depth'] ?? 0;
|
||||||
|
$commentMap[$row['comment_id']] = $row;
|
||||||
|
}
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
// Build threaded structure if threading is enabled (no pagination — all loaded)
|
||||||
|
if ($hasThreading && $threaded) {
|
||||||
|
$rootComments = [];
|
||||||
|
foreach ($commentMap as $id => $comment) {
|
||||||
|
if ($comment['parent_comment_id'] === null) {
|
||||||
|
$rootComments[] = $this->buildCommentThread($comment, $commentMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $rootComments;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $comments;
|
return array_values($commentMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addComment($ticketId, $commentData, $userId = null) {
|
|
||||||
$sql = "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled)
|
|
||||||
VALUES (?, ?, ?, ?, ?)";
|
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
/**
|
||||||
|
* Paginated threaded comments: fetch one page of root comments + all their replies.
|
||||||
|
*/
|
||||||
|
private function getThreadedCommentsPaged(int $ticketId, int $limit, int $offset): array
|
||||||
|
{
|
||||||
|
// Page of root comments
|
||||||
|
$rootSql = "SELECT tc.*, u.display_name, u.username
|
||||||
|
FROM ticket_comments tc
|
||||||
|
LEFT JOIN users u ON tc.user_id = u.user_id
|
||||||
|
WHERE tc.ticket_id = ? AND tc.parent_comment_id IS NULL
|
||||||
|
ORDER BY tc.created_at DESC
|
||||||
|
LIMIT ? OFFSET ?";
|
||||||
|
$stmt = $this->conn->prepare($rootSql);
|
||||||
|
$stmt->bind_param("iii", $ticketId, $limit, $offset);
|
||||||
|
$stmt->execute();
|
||||||
|
$rootResult = $stmt->get_result();
|
||||||
|
$stmt->close();
|
||||||
|
|
||||||
|
$commentMap = [];
|
||||||
|
$rootIds = [];
|
||||||
|
while ($row = $rootResult->fetch_assoc()) {
|
||||||
|
$row['display_name_formatted'] = $row['display_name'] ?: ($row['user_name'] ?? 'Unknown User');
|
||||||
|
$row['replies'] = [];
|
||||||
|
$row['parent_comment_id'] = null;
|
||||||
|
$row['thread_depth'] = 0;
|
||||||
|
$commentMap[$row['comment_id']] = $row;
|
||||||
|
$rootIds[] = $row['comment_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($rootIds)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// All replies for these root comments (up to 3 levels deep)
|
||||||
|
$placeholders = implode(',', array_fill(0, count($rootIds), '?'));
|
||||||
|
$replySql = "SELECT tc.*, u.display_name, u.username
|
||||||
|
FROM ticket_comments tc
|
||||||
|
LEFT JOIN users u ON tc.user_id = u.user_id
|
||||||
|
WHERE tc.ticket_id = ?
|
||||||
|
AND tc.parent_comment_id IN ($placeholders)
|
||||||
|
AND tc.parent_comment_id IS NOT NULL
|
||||||
|
ORDER BY tc.created_at ASC";
|
||||||
|
$replyStmt = $this->conn->prepare($replySql);
|
||||||
|
$types = 'i' . str_repeat('i', count($rootIds));
|
||||||
|
$replyStmt->bind_param($types, $ticketId, ...$rootIds);
|
||||||
|
$replyStmt->execute();
|
||||||
|
$replyResult = $replyStmt->get_result();
|
||||||
|
$replyStmt->close();
|
||||||
|
|
||||||
|
while ($row = $replyResult->fetch_assoc()) {
|
||||||
|
$row['display_name_formatted'] = $row['display_name'] ?: ($row['user_name'] ?? 'Unknown User');
|
||||||
|
$row['replies'] = [];
|
||||||
|
$row['thread_depth'] = $row['thread_depth'] ?? 1;
|
||||||
|
$commentMap[$row['comment_id']] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rootComments = [];
|
||||||
|
foreach ($rootIds as $rid) {
|
||||||
|
if (isset($commentMap[$rid])) {
|
||||||
|
$rootComments[] = $this->buildCommentThread($commentMap[$rid], $commentMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $rootComments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if threading columns exist
|
||||||
|
*/
|
||||||
|
private function hasThreadingSupport()
|
||||||
|
{
|
||||||
|
static $hasSupport = null;
|
||||||
|
if ($hasSupport !== null) {
|
||||||
|
return $hasSupport;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->conn->query("SHOW COLUMNS FROM ticket_comments LIKE 'parent_comment_id'");
|
||||||
|
$hasSupport = ($result && $result->num_rows > 0);
|
||||||
|
return $hasSupport;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively build comment thread
|
||||||
|
*/
|
||||||
|
private function buildCommentThread($comment, &$allComments)
|
||||||
|
{
|
||||||
|
$comment['replies'] = [];
|
||||||
|
foreach ($allComments as $c) {
|
||||||
|
if (
|
||||||
|
(int)$c['parent_comment_id'] === (int)$comment['comment_id']
|
||||||
|
&& isset($allComments[$c['comment_id']])
|
||||||
|
) {
|
||||||
|
$comment['replies'][] = $this->buildCommentThread($c, $allComments);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sort replies by date ascending
|
||||||
|
usort($comment['replies'], function ($a, $b) {
|
||||||
|
return strtotime($a['created_at']) - strtotime($b['created_at']);
|
||||||
|
});
|
||||||
|
return $comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get flat list of comments (for backward compatibility)
|
||||||
|
*/
|
||||||
|
public function getCommentsByTicketIdFlat($ticketId)
|
||||||
|
{
|
||||||
|
return $this->getCommentsByTicketId($ticketId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addComment($ticketId, $commentData, $userId = null)
|
||||||
|
{
|
||||||
|
// Check if threading is supported
|
||||||
|
$hasThreading = $this->hasThreadingSupport();
|
||||||
|
|
||||||
// Set default username (kept for backward compatibility)
|
// Set default username (kept for backward compatibility)
|
||||||
$username = $commentData['user_name'] ?? 'User';
|
$username = $commentData['user_name'] ?? 'User';
|
||||||
$markdownEnabled = isset($commentData['markdown_enabled']) && $commentData['markdown_enabled'] ? 1 : 0;
|
$markdownEnabled = isset($commentData['markdown_enabled']) && $commentData['markdown_enabled'] ? 1 : 0;
|
||||||
|
|
||||||
// Preserve line breaks in the comment text
|
|
||||||
$commentText = $commentData['comment_text'];
|
$commentText = $commentData['comment_text'];
|
||||||
|
$parentCommentId = $commentData['parent_comment_id'] ?? null;
|
||||||
|
$threadDepth = 0;
|
||||||
|
|
||||||
$stmt->bind_param(
|
// Calculate thread depth if replying to a comment
|
||||||
"sissi",
|
if ($hasThreading && $parentCommentId) {
|
||||||
$ticketId,
|
$parentComment = $this->getCommentById($parentCommentId);
|
||||||
$userId,
|
if ($parentComment) {
|
||||||
$username,
|
$threadDepth = min(($parentComment['thread_depth'] ?? 0) + 1, 3); // Max depth of 3
|
||||||
$commentText,
|
}
|
||||||
$markdownEnabled
|
}
|
||||||
);
|
|
||||||
|
if ($hasThreading) {
|
||||||
|
$sql = "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled, parent_comment_id, thread_depth)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param(
|
||||||
|
"sissiii",
|
||||||
|
$ticketId,
|
||||||
|
$userId,
|
||||||
|
$username,
|
||||||
|
$commentText,
|
||||||
|
$markdownEnabled,
|
||||||
|
$parentCommentId,
|
||||||
|
$threadDepth
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$sql = "INSERT INTO ticket_comments (ticket_id, user_id, user_name, comment_text, markdown_enabled)
|
||||||
|
VALUES (?, ?, ?, ?, ?)";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param(
|
||||||
|
"sissi",
|
||||||
|
$ticketId,
|
||||||
|
$userId,
|
||||||
|
$username,
|
||||||
|
$commentText,
|
||||||
|
$markdownEnabled
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if ($stmt->execute()) {
|
if ($stmt->execute()) {
|
||||||
$commentId = $this->conn->insert_id;
|
$commentId = $this->conn->insert_id;
|
||||||
@@ -62,7 +309,9 @@ class CommentModel {
|
|||||||
'user_name' => $username,
|
'user_name' => $username,
|
||||||
'created_at' => date('M d, Y H:i'),
|
'created_at' => date('M d, Y H:i'),
|
||||||
'markdown_enabled' => $markdownEnabled,
|
'markdown_enabled' => $markdownEnabled,
|
||||||
'comment_text' => $commentText
|
'comment_text' => $commentText,
|
||||||
|
'parent_comment_id' => $parentCommentId,
|
||||||
|
'thread_depth' => $threadDepth
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
return [
|
return [
|
||||||
@@ -71,5 +320,101 @@ class CommentModel {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single comment by ID
|
||||||
|
*/
|
||||||
|
public function getCommentById($commentId)
|
||||||
|
{
|
||||||
|
$sql = "SELECT tc.*, u.display_name, u.username
|
||||||
|
FROM ticket_comments tc
|
||||||
|
LEFT JOIN users u ON tc.user_id = u.user_id
|
||||||
|
WHERE tc.comment_id = ?";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("i", $commentId);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
return $result->fetch_assoc();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing comment
|
||||||
|
* Only the comment owner or an admin can update
|
||||||
|
*/
|
||||||
|
public function updateComment($commentId, $commentText, $markdownEnabled, $userId, $isAdmin = false)
|
||||||
|
{
|
||||||
|
// First check if user owns this comment or is admin
|
||||||
|
$comment = $this->getCommentById($commentId);
|
||||||
|
|
||||||
|
if (!$comment) {
|
||||||
|
return ['success' => false, 'error' => 'Comment not found'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($comment['user_id'] !== (int)$userId && !$isAdmin) {
|
||||||
|
return ['success' => false, 'error' => 'You do not have permission to edit this comment'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if updated_at column exists
|
||||||
|
$hasUpdatedAt = false;
|
||||||
|
$colCheck = $this->conn->query("SHOW COLUMNS FROM ticket_comments LIKE 'updated_at'");
|
||||||
|
if ($colCheck && $colCheck->num_rows > 0) {
|
||||||
|
$hasUpdatedAt = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasUpdatedAt) {
|
||||||
|
$sql = "UPDATE ticket_comments SET comment_text = ?, markdown_enabled = ?, updated_at = NOW() WHERE comment_id = ?";
|
||||||
|
} else {
|
||||||
|
$sql = "UPDATE ticket_comments SET comment_text = ?, markdown_enabled = ? WHERE comment_id = ?";
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$markdownInt = $markdownEnabled ? 1 : 0;
|
||||||
|
$stmt->bind_param("sii", $commentText, $markdownInt, $commentId);
|
||||||
|
|
||||||
|
if ($stmt->execute()) {
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'comment_id' => $commentId,
|
||||||
|
'comment_text' => $commentText,
|
||||||
|
'markdown_enabled' => $markdownInt,
|
||||||
|
'updated_at' => $hasUpdatedAt ? date('M d, Y H:i') : null
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
return ['success' => false, 'error' => $this->conn->error];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a comment
|
||||||
|
* Only the comment owner or an admin can delete
|
||||||
|
*/
|
||||||
|
public function deleteComment($commentId, $userId, $isAdmin = false)
|
||||||
|
{
|
||||||
|
// First check if user owns this comment or is admin
|
||||||
|
$comment = $this->getCommentById($commentId);
|
||||||
|
|
||||||
|
if (!$comment) {
|
||||||
|
return ['success' => false, 'error' => 'Comment not found'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($comment['user_id'] !== (int)$userId && !$isAdmin) {
|
||||||
|
return ['success' => false, 'error' => 'You do not have permission to delete this comment'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketId = $comment['ticket_id'];
|
||||||
|
|
||||||
|
$sql = "DELETE FROM ticket_comments WHERE comment_id = ?";
|
||||||
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
$stmt->bind_param("i", $commentId);
|
||||||
|
|
||||||
|
if ($stmt->execute()) {
|
||||||
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'comment_id' => $commentId,
|
||||||
|
'ticket_id' => $ticketId
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
return ['success' => false, 'error' => $this->conn->error];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
?>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user