342 Commits
Author SHA1 Message Date
F4ilji b53db18826 fix: add personal data policy link to consent banner 2026-09-18 23:59:12 +05:00
F4ilji da7e6368f7 fix: update consent banner text 2026-09-18 23:58:23 +05:00
F4ilji 1efa9df9ba fix: update consent banner text on public site 2026-09-18 23:55:50 +05:00
F4ilji 19c0cdef5b fix: remove links from consent banner 2026-09-18 23:52:32 +05:00
F4ilji 5ef1049be3 feat: update consent banner with detailed cookie policy text 2026-09-18 23:47:40 +05:00
F4ilji cfd7c411cf fix: update git remote to self-hosted Gitea 2026-09-18 23:35:59 +05:00
F4ilji d6c0f48d48 feat(analytics): add site analytics with grouped navigation sections
- Add Analytics container: models, tasks, jobs, controllers, routes, services
- Add analytics config with section groups (structure, institute, content, services)
- Add database tables: analytics_sessions, analytics_hits, analytics_daily_stats
- Add frontend: Analytics page, section detail, chart, overview, navigation components
- Add consent banner and JS tracking service (sendBeacon + Inertia listener)
- Add permission sync for view_analytics, view_any_analytic
- Fix trailing slash URL matching in GetNavigationSectionsTask
- Fix event propagation on expand arrows in navigation sections
- Fix RecordVisitJob to not depend on dropped analytics_geo_cache table
- Replace plain text views/visitors with badge-style icons
- Group navigation sections by category with separate blocks
- Add config-driven non-CMS sections (faculties, divisions, DPO, journals)
- Remove Events, TV, Sveden from analytics sections
2026-09-18 22:18:04 +05:00
F4ilji 8af94388ab fix: make notifications migration idempotent 2026-09-18 02:58:52 +05:00
F4ilji 7ff860feca feat: block destructive DB commands in production
Listen for CommandStarting event and throw RuntimeException for
migrate:fresh, migrate:refresh, migrate:reset, db:wipe, db:seed
when APP_ENV=production. Prevents accidental data loss even with --force.

Also removes old non-functional closure-based override in routes/console.php.
2026-09-18 02:54:42 +05:00
F4ilji 4a776a42e2 fix: resolve image upload issues in posts
- Dashboard: disable submit button during uploads, show upload errors, guard against pending uploads
- Filament: add maxParallelUploads(1) to fix race condition (filamentphp#13306)
- Filament: add image/webp to gallery acceptedFileTypes, increase resize 30->50
- Dashboard: add image/webp to gallery input accept
- Add missing notifications table migration for Filament databaseNotifications
2026-09-18 02:25:36 +05:00
F4ilji 1853b087f4 chore: update seeders, add MockDataSeeder and AssignAllPermissions command
- DatabaseSeeder: replace old seeders with MockDataSeeder
- RolesSeeder: update role definitions
- Add AssignAllPermissions artisan command
- Register MockDataSeeder in AppServiceProvider
- Register command in ConsoleKernel
2026-09-18 01:39:28 +05:00
F4ilji 02455fbca7 feat(factories): add new factories and update existing ones for all models 2026-09-18 01:39:25 +05:00
F4ilji c7a779d9aa refactor(models): replace HasFactory with HasContainerFactory across all models
Add static $factory property for Porto-style model factory resolution
2026-09-18 01:39:22 +05:00
F4ilji cf18dd660a fix: add missing HasContainerFactory trait to repo 2026-09-18 01:38:01 +05:00
F4ilji cc08cdbc97 fix: 405 error on schedule upload/edit
- Edit.vue: move _method PUT from Inertia options to FormData (Inertia v2 ignores it in options)
- Upload.vue: add forceFormData: true for reliable multipart/form-data
- AccessCheck: add logging when abort() is triggered with non-200 code
- UploadSchedulesController: add request logging for diagnostics
- php.ini: increase max_file_uploads from 20 to 50 for bulk uploads
2026-09-18 01:32:34 +05:00
F4ilji bf77cb634c fix(departments): fix contact info editing, cache invalidation, and pivot data display
- User model: add missing pivot columns (service_email, service_phone, cabinet, sort) to departments_work() and departments_teach() withPivot
- ListDepartmentWorkersAction/TeachersAction: fix where() to wherePivot() for position filters
- Dashboard Workers/Teachers Index.vue: display validation errors in add/edit modals
- Dashboard Workers/Teachers Index.vue: add links to client person pages
- DepartmentCacheService: clear person_* cache when department data changes
- Filament WorkersRelationManager/TeachersRelationManager: fix pivot columns in table (pivot.position etc), add cache clearing on edit/detach/bulk actions
2026-09-18 01:32:27 +05:00
F4ilji e0e80fc430 fix: hide slider on main page when no slides available 2026-09-17 14:12:54 +05:00
F4ilji 9023fdd7e7 fix(schedule): restore original Index.vue with SSR-safe fixes 2026-08-31 20:31:35 +05:00
F4ilji d6a10d46c7 fix(schedule): case-sensitive import MainPageNavbar (Linux) 2026-08-31 20:23:36 +05:00
F4ilji a1183f91f6 fix(schedule): merge duplicate script blocks into one 2026-08-31 20:20:59 +05:00
F4ilji 36c122cf65 fix(deploy): restart SSR after build to pick up new SSR bundle 2026-08-31 20:14:55 +05:00
F4ilji 774de4a6ab fix(deploy): explicit SSR restart after build + defensive props 2026-08-31 20:05:39 +05:00
F4ilji 712f172071 fix(schedule): SSR-safe props to prevent null component hydration error 2026-08-31 19:45:12 +05:00
F4ilji 7052b35c19 fix(seeder): remove is_zaoch column not in schedules table 2026-08-20 11:38:36 +05:00
F4ilji 9b0c8f8158 feat(seeder): add ScheduleMockSeeder with 40 fake records 2026-08-20 11:34:08 +05:00
F4ilji e6d223d15c feat(schedules): add 'select all N' across pages
- Add GetAllScheduleIdsAction + GetFilteredScheduleIdsTask
- Add GET /dashboard/schedules/all-ids route returning filtered IDs
- Add selectAllFiltered method to Index.vue
- Button 'Выбрать все N' appears when total > per-page count
2026-08-20 11:31:04 +05:00
F4ilji 24387d6767 feat(schedules): add bulk delete via checkboxes
- Add BulkDeleteSchedulesTask, BulkDeleteSchedulesAction
- Add route DELETE /dashboard/schedules/bulk-destroy
- Add bulkDestroy method to ScheduleController
- Update Index.vue: select all/individual checkboxes, bulk actions bar
- Fix CheckDashboardPermission: replace naive /s$/ regex with Str::singular()
  to resolve irregular plurals (faculties→faculty, categories→category)
2026-08-20 11:16:53 +05:00
F4ilji f8d55092e5 fix: add null guard in generateSlug to prevent SSR crash on null tab titles 2026-08-03 11:52:36 +05:00
F4ilji 63e456088d feat(dashboard): add Roles CRUD with permission-based menu filtering
- Roles CRUD: List/Create/Update/Delete actions, controller, requests
- Roles Vue pages: Index, Create, Edit (Options API, DashboardLayout)
- SidebarNav: filter menu items by user permissions via Inertia shared data
- HandleInertiaRequests: share permissions and roles arrays to frontend
- HttpKernel: register dashboard.permission middleware alias
- SidebarNavItem: add shield-check icon mapping
2026-08-03 10:39:15 +05:00
F4ilji d4ef7daeb1 feat(dashboard): add page export/import functionality
- ExportPageAction: exports page with SEO and section path as JSON
- ImportPageAction: imports page from JSON, resolves section by slug path
- PageController: export/download and import endpoints
- Routes: GET /{page}/export, POST /import
- Index.vue: export button per row, import button in header (local only)
- Import restricted to APP_ENV=local (backend + frontend check)
2026-08-03 10:21:14 +05:00
F4ilji 3860c5d24c fix(permissions): remove direct user permission sync — roles only
UpdateUserAction and CreateUserAction were calling syncPermissions()
which wrote to model_has_permissions table directly. This meant
permissions persisted even after being removed from the role.

Now users only get permissions through their roles.
2026-07-16 14:09:30 +05:00
F4ilji b7259fdd6f refactor(permissions): replace hardcoded route middleware with auto-resolving CheckDashboardPermission
- New middleware extracts permission name from route name automatically
- dashboard.posts.index → view_any_post
- dashboard.admission-campaigns.store → view_any_admission_campaign
- vikon-updates, integration-credentials excluded from check
- Single middleware on parent group instead of 28 individual ones
2026-07-16 12:05:15 +05:00
F4ilji b8fd42d9f8 fix(security): add permission middleware to dashboard routes
Each route group now requires view_any_{resource} permission.
Without the permission, Spatie returns 403 Forbidden.
VIKON updates and integration-credentials remain unprotected.
2026-07-16 11:57:20 +05:00
F4ilji 4f4bed0899 fix(menu): use 'view_any_role' instead of 'view_any_shield::role'
Permission name mismatch between menuConfig and DB was hiding the Roles tab.
2026-07-16 11:35:44 +05:00
F4ilji 9ac4f89e36 feat(jenkins): add dashboard:sync-permissions after migrations
Ensures all dashboard permissions exist on every deploy.
Command is idempotent — safe to run repeatedly.
2026-07-11 14:22:05 +05:00
F4ilji c6ca3258c1 feat(permissions): add dashboard:sync-permissions artisan command
Auto-discovers all dashboard permissions from resource/prefix matrix,
creates missing ones in DB, syncs to super_admin and admin roles.
Run on production after deploy or when adding new menu sections.
2026-07-11 14:17:11 +05:00
F4ilji 4e2105190b fix(tests): replace RefreshDatabase with DatabaseTransactions
- Prevents local DB data loss when running 'php artisan test'
- ViconApiConfigTest uses updateOrCreate to avoid unique constraint
- Delete + Cache::flush for 'not configured' test case
2026-07-10 17:11:27 +05:00
F4ilji c59a08b3a0 test(vikon): add live API integration tests
- getNaprs, getPrograms, getProgramByUuid — real API calls
- EducationalProgramService cross-check
- invalid token rejection via Http::fake
2026-07-10 16:55:30 +05:00
F4ilji 0ea2b7d9ef test(vikon): add ViconApiConfig unit tests
- token returns value from DB
- apiUrl returns value from DB
- apiUrl falls back to constant when not in payload
- token throws RuntimeException when not configured
- token throws when payload is empty
- inactive credential is ignored
2026-07-10 16:51:34 +05:00
F4ilji 73a0a6fe9c feat(vikon): extract API URL from hardcoded to integration_credentials
- Rename ViconApiToken → ViconApiConfig with token() and apiUrl()
- Replace 9 hardcoded db-nica.ru URLs with dynamic apiUrl()
- Payload now stores both token and api_url with fallback
2026-07-10 16:47:18 +05:00
F4ilji 7748b05547 feat(vikon): move API token from .env to integration_credentials
- Rename VICON_TOKEN → VIKON_API_TOKEN in .env and config
- Add ViconApiToken service reading from integration_credentials table
- Update 3 Vicon services to use ViconApiToken instead of config()
- Config/env kept as fallback for clean migration
2026-07-10 16:43:09 +05:00
F4ilji 8db442a34a Revert "fix(ssr): add Vue lifecycle guard plugin to suppress browser API errors in SSR hooks"
This reverts commit bb74bb48ba.
2026-07-07 11:22:32 +05:00
F4ilji bb74bb48ba fix(ssr): add Vue lifecycle guard plugin to suppress browser API errors in SSR hooks 2026-07-06 21:18:33 +05:00
F4ilji 6db62d9961 fix(ssr): suppress lifecycle hook errors (nextSibling null) at process level to prevent SSR crashes 2026-07-06 21:16:12 +05:00
F4ilji 63b71b87df fix(ssr): always return {head,body} from createInertiaApp — .then() fallback for undefined resolve 2026-07-06 18:59:24 +05:00
F4ilji f575325fa7 fix(ssr): remove window polyfill that broke Inertia SSR detection (typeof window check) 2026-07-06 18:53:06 +05:00
F4ilji 5fddb97cbf fix(ssr): return empty string on SSR failure instead of throwing to prevent undefined response 2026-07-06 18:30:26 +05:00
F4ilji 778ea67cb4 fix(ssr): add addEventListener/removeEventListener to document and window polyfills 2026-07-06 18:22:22 +05:00
F4ilji 48ab0b384e fix(ssr): add getElementById and other DOM methods to document polyfill 2026-07-06 18:05:35 +05:00
F4ilji 4c0152ae0f fix(ssr): polyfill localStorage/sessionStorage, guard BasicListBadge filters prop, remove this. from template 2026-07-06 17:57:28 +05:00
F4ilji eda69f4e37 fix(ssr): polyfill IntersectionObserver for Node.js SSR runtime 2026-07-06 17:53:46 +05:00
F4ilji 520533ef1f fix(ssr): null-safe campaign access to prevent TypeError during SSR 2026-07-06 14:11:38 +05:00
F4ilji 3ee875a6db fix(handler): fallback to HTML when Vite manifest missing during error rendering 2026-07-06 13:41:52 +05:00
F4ilji 9bac98d175 fix(logging): route EmailFetchException to email channel instead of app 2026-07-06 13:39:26 +05:00
F4ilji 65f4220932 fix(vikon): store version in file instead of cache to prevent revert after TTL expiry 2026-07-06 13:28:22 +05:00
F4ilji 9f4dfd9bfc ci: optimize Jenkinsfile — parallel pre-deploy, npm ci, fix rollback composer path 2026-07-06 12:39:40 +05:00
F4ilji 9943f7ab90 fix error 2026-07-06 12:35:40 +05:00
F4ilji b454c1ce97 fix(ssr): add null-check for breadcrumbs.page in Page.vue to prevent TypeError during SSR 2026-07-05 23:51:40 +05:00
F4ilji 769b2ff342 feat(logging): migrate remaining files to app channel — all Log:: calls now use domain channels 2026-07-05 23:32:01 +05:00
F4ilji 6bc1b3e921 feat(logging): migrate Education container to education channel 2026-07-05 23:30:30 +05:00
F4ilji cd9b682994 feat(logging): migrate AI tasks to ai channel 2026-07-05 23:29:39 +05:00
F4ilji 49dd578e50 feat(logging): migrate email pipeline to email channel 2026-07-05 23:28:23 +05:00
F4ilji 41b00fcdae feat(logging): migrate VK API jobs and services to vk channel 2026-07-05 23:26:41 +05:00
F4ilji 720aa4d0ae feat(logging): migrate VikonIntegration to vikon channel 2026-07-05 23:13:51 +05:00
F4ilji dfe8de0680 feat(logging): add 6 domain-specific daily channels to config 2026-07-05 23:11:27 +05:00
F4ilji e3eda2adbc fix(vikon): revert to api_domain for authorize URL, use vikon_core redirect_uri 2026-07-05 22:39:21 +05:00
F4ilji fd7de347d4 fix: null-safe role check, enable SQLite test DB, improve TinyMCE paste handling 2026-07-05 22:34:04 +05:00
F4ilji bec37c0e22 fix(vikon): use auth_domain for OAuth authorize URL instead of api_domain 2026-07-05 22:31:51 +05:00
F4ilji a372f725cd fix(ssr): embed Ziggy routes as fallback for SSR context
ZiggyVue needs routes in its config, but page.props.ziggy from the
server may not carry them in the SSR worker. Importing the static
Ziggy config ensures routes are always available.
2026-07-05 21:56:51 +05:00
F4ilji 09771bf8ad fix(ssr): remove conflicting RouteMixin that caused 'Cannot read properties of undefined (reading index)'
RouteMixin received the full Inertia page object instead of page.props,
producing a Ziggy config without routes. ZiggyVue already provides the
route() function with the correct config, so the duplicate mixin is
unnecessary.
2026-07-05 21:47:03 +05:00
F4ilji a80a60bdbe feat(vikon): redesign UI with update all button, specialized icons, and manual ZIP upload 2026-07-05 18:00:49 +05:00
F4ilji a32572f5e8 fix(vikon): remove env override for version, use hardcoded default 2026-07-05 17:44:45 +05:00
F4ilji eb142b2513 refactor(vikon): use Redis cache for version instead of file 2026-07-05 15:11:17 +05:00
F4ilji ad8a4d861b fix(vikon): read version from file and update after core update 2026-07-05 15:06:14 +05:00
F4ilji b0ff3bf397 feat(vikon): cleanup _old/_new artifacts after successful sync 2026-07-05 13:58:00 +05:00
F4ilji 416403b6ce debug(vikon): add detailed logging for ZIP size and FM sync stats 2026-07-05 13:33:13 +05:00
F4ilji b9ff255878 fix(vikon): use cURL streaming for ZIP downloads to avoid memory/timeout issues 2026-07-05 13:26:10 +05:00
F4ilji a18765aafa feat(vikon): merge update buttons into single flow with all parts selected by default 2026-07-05 13:10:49 +05:00
F4ilji af86841ebc feat(vikon): improve part update UI with per-part status indicators and Russian messages 2026-07-05 12:53:44 +05:00
F4ilji 08c04c5615 feat(vikon): show disabled modules with warning instead of hiding them 2026-07-05 12:34:44 +05:00
F4ilji efce5be699 fix(vikon): use moduleData['id'] instead of array key, handle additional_access_flags 2026-07-05 12:25:15 +05:00
F4ilji 97c4ab99f1 debug(vikon): log raw API response structure for parts mapping 2026-07-05 12:19:14 +05:00
F4ilji a162154f70 fix(vikon): cast API module IDs to int for Vue compatibility 2026-07-05 12:12:04 +05:00
F4ilji 0a5f99e6ae refactor(vikon): remove hardcoded module IDs and use config everywhere 2026-07-05 11:40:02 +05:00
F4ilji 8cf195efb0 refactor(vikon): fetch parts from VIKON API instead of hardcoded config 2026-07-05 11:32:54 +05:00
F4ilji 9a9521c570 fix(vikon): initialize selectedParts arrays to prevent crash on checkbox click 2026-07-05 11:11:24 +05:00
F4ilji af1fb094d2 fix(vikon): check HTTP status instead of JSON field for part generation check 2026-07-05 10:55:37 +05:00
F4ilji 3d9982e5be fix(vikon): handle numeric status codes from VIKON API (1=completed, -1=failed) 2026-07-05 10:46:02 +05:00
F4ilji 2bf2f4a861 fix(vikon): send POST data as form-urlencoded to match VIKON API expectations 2026-07-05 10:31:40 +05:00
F4ilji e83cd0109d fix(vikon): correct VIKON API endpoint names for part updates 2026-07-05 10:21:27 +05:00
F4ilji d83364432e feat(vikon): replace part select with checkboxes for multi-selection 2026-07-05 09:55:45 +05:00
F4ilji 11b2def517 test(vikon): add validateFileTypes mock to UpdatePartActionTest 2026-07-05 03:02:03 +05:00
F4ilji e8e36156e9 fix(vikon): add file type validation and temp cleanup in UpdatePartAction 2026-07-05 03:01:12 +05:00
F4ilji 059705d60d test(vikon): add feature tests for part update endpoint 2026-07-05 02:58:32 +05:00
F4ilji 1d423adc12 feat(vikon): add part selection UI to Vue dashboard 2026-07-05 02:52:50 +05:00
F4ilji b259809960 feat(vikon): add updatePart endpoint with request validation 2026-07-05 02:50:41 +05:00
F4ilji 15f72b4de1 feat(vikon): add restoreAfterFail for crash recovery in FilesystemTask 2026-07-05 02:47:40 +05:00
F4ilji da90011ad3 feat(vikon): add UpdatePartAction for partial module updates 2026-07-05 02:45:47 +05:00
F4ilji 514123a19f feat(vikon): add parts config for partial updates 2026-07-05 02:42:51 +05:00
F4ilji 4764d4e2d9 feat(vikon): add PollPartStatusTask for part update polling 2026-07-05 02:41:23 +05:00
F4ilji a624b83f18 feat(vikon): add atomicSwap to FilesystemTask for crash-safe updates 2026-07-05 02:35:25 +05:00
F4ilji 982e80027d docs: add partial updates implementation plan 2026-07-05 02:31:27 +05:00
F4ilji 5c081261b0 docs: add partial updates design spec for VikonIntegration module 2026-07-05 02:29:18 +05:00
F4ilji cc69c3566b docs: add dashboard error page final report and plan 2026-07-05 01:21:39 +05:00
F4ilji df6110adf8 feat(dashboard): modify exception handlers to render DashboardError for dashboard routes 2026-07-05 01:19:04 +05:00
F4ilji a4fef0bb3d feat(dashboard): add DashboardError.vue with full error context display 2026-07-05 01:18:04 +05:00
F4ilji e3511462f7 fix(vikon): skip existing files during FM sync, match original PHP diff behavior
Original vikon_core sync_root_dir.php only downloads files that are
missing locally or have size 0. Added local file size check before
downloading to avoid re-fetching 14GB+ of existing files.
2026-07-04 23:25:59 +05:00
F4ilji b538b4119f fix(vikon): all FM files go to files/ dir, matching original PHP getFsPathByModule
Original vikon_core Path::getFsPathByModule() returns moduleRoot/files/,
not moduleRoot. Root FM files, 3-letter subdirs, and new files all go
under files/ directory. index.html etc come from core ZIP, not FM sync.
2026-07-04 23:02:43 +05:00
F4ilji cccdbbbbd8 fix(vikon): keep ZIP and extracted files in temp after update 2026-07-04 22:41:39 +05:00
F4ilji a33d93e163 fix(vikon): root files to module root, 3-letter dirs to files/{code}/
- UpdateCoreAction: split syncDirFiles into syncRootDir (→module root)
  and syncSubDir (→files/{dirId})
- SyncFilesAction: same split, matching original PHP vikon_core behavior
- index.html, file_stubs/ etc go to module root
- 3-letter FM codes (eib, eid, rir) go inside files/
2026-07-04 22:40:30 +05:00
F4ilji a7d0628a13 fix(vikon): use module root as FM sync base path instead of files/ subdir
Root files (index.html, file_stubs/) and non-3-letter directories now go
to module root where VIKON expects them, not into files/ subdirectory.
dir_name from FM API is relative to module root.
2026-07-04 22:31:10 +05:00
F4ilji bf3bcc4eca fix(vikon): revert syncDirFiles - keep 3-letter dirIds as subdirectories 2026-07-04 22:18:20 +05:00
F4ilji c4314e2f43 fix(vikon): use getFileByIdentityInfo dir_name for correct file paths 2026-07-04 22:15:59 +05:00
F4ilji d7c9de2bd6 fix(vikon): restore syncDirFiles subdirs - files go to files/{dirId}/ 2026-07-04 21:55:29 +05:00
F4ilji 241ac576fe fix(vikon): revert syncDirFiles - save files directly in files/ without subdirs 2026-07-04 21:52:21 +05:00
F4ilji 997ef6d483 fix(vikon): syncDirFiles saves to correct subdirectory, increase timeouts to 600s 2026-07-04 21:49:17 +05:00
F4ilji b115cb93fd fix(vikon): increase nginx fastcgi_read_timeout to 600s, PHP set_time_limit 600 2026-07-04 21:40:51 +05:00
F4ilji 204e31c06d fix(vikon): show detailed error message with HTTP status code 2026-07-04 21:33:20 +05:00
F4ilji 87732720ef feat(vikon): merge core update + FM sync into single Update button 2026-07-04 21:25:29 +05:00
F4ilji 20382da0bc fix(vikon): rewrite SyncFilesAction - always download, add logging 2026-07-04 21:11:00 +05:00
F4ilji 0811c4f1fc fix(vikon): add parameter to SyncFilesAction singleton 2026-07-04 20:55:14 +05:00
F4ilji 8e5f26da8d feat(vikon): add file sync from VIKON file manager (FM) 2026-07-04 20:41:49 +05:00
F4ilji e64f9cca83 fix(vikon): save ABITUR API response in temp for reference 2026-07-04 20:26:25 +05:00
F4ilji b4829492e5 fix(vikon): keep ZIP archive in temp after update 2026-07-04 20:17:33 +05:00
F4ilji d358e6fdb0 fix(vikon): keep downloaded files in temp after update 2026-07-04 20:16:56 +05:00
F4ilji b6140b77f5 vsoko conf changes 2026-07-04 20:03:19 +05:00
F4ilji b94e0aad87 fix(vikon): replace router.reload with alert after update success 2026-07-04 19:37:49 +05:00
F4ilji e0cf362433 fix(vikon): create target directories before rename in syncFiles 2026-07-04 19:29:50 +05:00
F4ilji 0097a803b4 fix(vikon): sync from extracted subdirectory inside ZIP 2026-07-04 19:21:32 +05:00
F4ilji 7d6b1a2342 fix(vikon): return actual error message in updateModule response 2026-07-04 19:16:43 +05:00
F4ilji 13df1c3a64 fix(vikon): simplify syncFiles - just overwrite, no backups or deletes 2026-07-04 19:11:25 +05:00
F4ilji 93bd3963bf fix(vikon): use rename() instead of copy() for syncFiles - atomic, no extra disk space 2026-07-04 19:09:13 +05:00
F4ilji e502225e9c fix(vikon): clean up corrupted CheckAccessAction file 2026-07-04 19:01:05 +05:00
F4ilji 44abdb471f fix(vikon): check writability only for module dirs (sveden, abitur, vsoko) 2026-07-04 18:46:23 +05:00
F4ilji cb9639f10f fix(vikon): fix excluded dirs matching for public/build and public/vikon_core 2026-07-04 18:45:34 +05:00
F4ilji 4cc3505050 fix(vikon): exclude non-writable dirs from access check (vendor, build, vikon_core) 2026-07-04 18:34:19 +05:00
F4ilji 11b145861d fix(vikon): use vikon_core redirect_uri in oauthCallback to match VIKON server registration 2026-07-04 18:28:34 +05:00
F4ilji 7b9cf25631 fix(vikon): skip authenticate call on OAuth callback, tokens already saved server-side 2026-07-04 18:22:51 +05:00
F4ilji 90d7cbc270 fix(vikon): redirect OAuth callback to /dashboard/vikon-updates/callback 2026-07-04 18:14:53 +05:00
F4ilji 3424553d39 fix(nginx): allow vikon_core/update/index.php for OAuth callback 2026-07-04 18:11:49 +05:00
F4ilji 15dd66b636 fix(vikon): register vikon.refresh in correct HttpKernel (Ship) 2026-07-04 17:55:53 +05:00
F4ilji 5600f21937 fix(vikon): rename authorize() to getAuthUrl() to avoid parent conflict 2026-07-04 17:46:14 +05:00
F4ilji 3dc29d44d8 fix(vikon): DNS bypass, OAuth callback, ABITUR init, token cache, ZIP security
- Add CURLOPT_RESOLVE DNS bypass for db-nica.ru / file.db-nica.ru
- Add OAuth callback route with CSRF state validation
- Add /authorize endpoint to generate OAuth URL with state
- Add ABITUR special case: init empty module core without ZIP download
- Add token validation caching (150s) to reduce API calls
- Block PHP/PHTML/PHAR files inside ZIP before extraction
- Add VikonTokenRefresh middleware for auto token refresh
- Add vikon.refresh middleware alias to Kernel
2026-07-04 17:44:50 +05:00
F4ilji 93721fc5ae fix(vikon): use correct VIKON API endpoints
- CheckAccessAction: pull_updates/assist/getModulesTreeAsync (not checkAccessJson)
- CheckVersionAction: pull_updates/assist/getUpdateVersionJson (not getLatestVersion)

These are the actual endpoints used by the original vikon_core update.js
2026-07-04 13:57:17 +05:00
F4ilji 967db81d3c fix(vikon): use correct token introspect endpoint for update system
Original vikon_core security.php validates tokens via:
  POST db-nica.ru/oauth2/resource/token/introspect
  Body: client_id=542&access_token=XXX

Our code was incorrectly using:
  GET auth.db-nica.ru/api/profile_applicant/check_access_token
  Header: Authorization: Bearer XXX

This was the WRONG endpoint — the profile check is for abitur forms,
not for the update system.
2026-07-04 13:54:56 +05:00
F4ilji a963647770 fix(vikon): use form-encoded POST + correct domain for OAuth token exchange
- HttpTask::post() now sends application/x-www-form-urlencoded (not JSON)
- AuthenticateAction sends to db-nica.ru (not auth.db-nica.ru) matching original vikon_core
- Original code: remoteRequest($apiDomen . 'oauth2/authorize/token', ...)
2026-07-04 13:46:49 +05:00
F4ilji 2ccfb24d1d fix(vikon): use vikon_core redirect_uri for OAuth (registered on VIKON server)
- vikon_core/update/index.php now redirects OAuth callback to /dashboard/vikon-updates
- Vue frontend sends redirect_uri=https://www.ntspi.ru/vikon_core/update/index.php
- This URL is registered on db-nica.ru for client_id=542
2026-07-04 13:43:42 +05:00
F4ilji 94e17dfbbc fix(vikon): use api_domain for OAuth authorize URL (not auth_domain)
Original vikon_core used db-nica.ru for /oauth2/authorize, not auth.db-nica.ru.
This was causing 'Client authentication failed' error.
2026-07-04 13:33:39 +05:00
F4ilji ba99b2a093 fix(vikon): switch validateFileTypes from blacklist to whitelist
Based on actual file analysis of sveden/abitur/vsoko modules:
- html, css, js, map (content + assets)
- png, jpg, gif, svg, ico, bmp (images)
- ttf, woff, woff2, eot (fonts)
- pdf, doc, docx (documents)
- vikon (module-specific)

No PHP/ASP/executables in allowed list — any non-whitelisted file is blocked.
2026-07-04 13:21:54 +05:00
F4ilji f4f8bc8e77 feat(vikon): rewrite VikonIntegration from scratch — OAuth + core update
- HttpTask: Laravel Http facade with SSL enabled, retry, timeout
- ValidateTokenTask: token check via auth.db-nica.ru
- RefreshTokenTask: token refresh via db-nica.ru
- FilesystemTask: path traversal protection, blocked extensions (PHP/ASP/etc)
- AuthenticateAction: OAuth2 code→token exchange
- CheckAccessAction: token validation + filesystem writability check
- CheckVersionAction: version comparison against remote API
- UpdateCoreAction: ZIP download, Zip Slip protection, atomic sync with rollback
- VikonController: thin controller, 7 endpoints with Session-based token storage
- Routes: access-check, dashboard.auth, throttle:30,1 middleware
- Vue 3 Composition API frontend with progress bar
- Config: all secrets in .env via config/vikon.php
- vikon_core kept as fallback (not deleted)
2026-07-04 12:53:28 +05:00
F4ilji 82df1210c2 feat(vikon): add config/vikon.php with module definitions 2026-07-04 12:41:22 +05:00
F4ilji 47aac647ac feat(vikon): remove broken VikonIntegration container - starting fresh 2026-07-04 12:40:41 +05:00
F4ilji 67b9aae961 feat(ci): add migration rollback and remove duplicate chown in Jenkinsfile
- Save pre-deploy SHA before git pull for automatic rollback
- On migrate failure: git reset + migrate:rollback + reinstall deps + restart
- Remove duplicate chown from Restart Services stage (already in Fix Permissions)
- Clean up .deploy_prev_sha after deploy completes
2026-07-03 09:57:34 +05:00
F4ilji 282d5a7c38 Add email attachments cleanup (older than 3 days) 2026-07-02 23:12:29 +05:00
F4ilji d1f216d04a Reduce backup retention to 3 days 2026-07-02 22:31:18 +05:00
F4ilji bb04a1af14 Add DB backup stage before deploy 2026-07-02 22:22:27 +05:00
F4ilji dcb0426264 Fix prod nginx: ^~ for Jenkins proxy 2026-07-02 21:48:07 +05:00
F4ilji b877153f3f Fix: use ^~ for Jenkins to override regex static locations 2026-07-02 21:46:15 +05:00
F4ilji db968ad9b0 Simplify Jenkins proxy: remove port exposure, add proxy_redirect 2026-07-02 21:43:56 +05:00
F4ilji acae8ae59d Fix: use ^~ for Jenkins static assets to override regex 2026-07-02 21:42:16 +05:00
F4ilji b041558cc3 Fix Jenkins proxy: separate static assets location 2026-07-02 21:41:32 +05:00
F4ilji c807947968 Fix Jenkins proxy with sub_filter for static assets 2026-07-02 21:40:29 +05:00
F4ilji e0ceecf157 Add Jenkins prefix /jenkins for nginx proxy 2026-07-02 21:39:35 +05:00
F4ilji aff60841b4 Add Jenkins proxy to test nginx config 2026-07-02 21:38:29 +05:00
F4ilji ba362902e6 Add Jenkins reverse proxy via nginx 2026-07-02 21:36:23 +05:00
F4ilji f652308421 Add jenkins service to docker-compose.yml 2026-07-02 21:15:27 +05:00
F4ilji d87bc581d1 Fix: remove dead placeholder view path in AppServiceProvider 2026-07-02 20:56:37 +05:00
F4ilji acef609a2f Fix composer open_basedir restriction 2026-07-02 20:54:14 +05:00
F4ilji 44ad8ef460 Fix: use docker exec instead of docker compose to avoid container name conflicts 2026-07-02 20:53:04 +05:00
F4ilji 25bc144ac7 Add Jenkinsfile for CI/CD pipeline 2026-07-02 20:51:45 +05:00
F4ilji 709c51ea49 remove: all deploy UI/backend code, keep _deploy folder only
- Delete DeployRunCommand, DeployController, deploy API routes
- Delete Dashboard/Deploy Vue pages and components
- Remove deploy route from web.php
- Remove deploy quick action from config
- Remove DeployRunCommand from ConsoleKernel
2026-07-02 17:36:42 +05:00
F4ilji c8693b8f0e fix: replace web middleware with minimal session+CSRF stack for deploy API 2026-07-02 17:34:27 +05:00
F4ilji 48ffd217b8 fix: secure deploy API, remove Docker-in-Docker, add DB backup
- Add web+superadmin middleware to /api/deploy routes
- Return JSON 403 in EnsureUserIsSuperadmin for API requests
- Remove Docker CLI and docker.sock from app container
- Replace docker compose restart with supervisorctl
- Add mysqldump backup step before migrations
2026-07-02 17:26:38 +05:00
F4ilji 6ed11cab2d change 2026-07-02 17:11:38 +05:00
F4ilji fce1d3f454 fix: proper route structure - API in api.php, web in web.php 2026-07-02 17:10:44 +05:00
F4ilji b2fec84080 fix: empty api.php since deploy routes moved to web 2026-07-02 17:05:55 +05:00
F4ilji 8b312e0ecd fix: move deploy API routes to web group with session auth 2026-07-02 17:01:32 +05:00
F4ilji 74d7c2b563 fix: add session middleware for API auth 2026-07-02 17:00:32 +05:00
F4ilji e0cb9642cd fix: add better error logging for deploy API 2026-07-02 16:55:43 +05:00
F4ilji 2b5b4ee48f error alert change 2026-07-02 16:49:55 +05:00
F4ilji ae4cdc5efc fix: correct path to API routes file 2026-07-02 16:45:59 +05:00
F4ilji ca23c6d669 fix: use auth:web middleware for API routes 2026-07-02 16:42:39 +05:00
F4ilji c80baf4807 fix: return JSON 401 for unauthenticated API requests 2026-07-02 13:38:03 +05:00
F4ilji aa6c5bc6be error alert change 2026-07-02 13:25:11 +05:00
F4ilji 92bb10faba fix: add CSRF token and cookies to deploy API requests 2026-07-02 13:17:24 +05:00
F4ilji 814c06bbb4 fix: correct path to dashboard API routes 2026-07-02 13:09:55 +05:00
F4ilji c12ac98387 fix: register deploy:run command in ConsoleKernel 2026-07-02 12:57:14 +05:00
F4ilji 6fb0079e7c feat: add deploy route and sidebar navigation 2026-07-02 12:50:09 +05:00
F4ilji 13d7d36088 feat: add deploy dashboard page with components 2026-07-02 12:48:49 +05:00
F4ilji 73d123a62d feat: add deploy API endpoints 2026-07-02 12:48:09 +05:00
F4ilji c216f929d4 feat: add deploy:run artisan command 2026-07-02 12:47:17 +05:00
F4ilji 8bc5c367d6 revert: remove deploy UI, controller, actions, tasks, and routes
- Remove Deploy/Index.vue, DeployController, DeploySiteAction, DeployTask
- Remove deploy routes from web.php
- Remove deploy menu item from menuConfig
- Remove deploy button and methods from Main.vue
- Remove deploy logging channel from logging.php
- Keep _deploy/ folder for future CI/CD setup
2026-07-02 12:00:48 +05:00
F4ilji cc98d509e5 fix: add Docker CLI to PHP container for deploy script
- Install docker-ce-cli + docker-compose-plugin in Dockerfile
- Mount Docker socket in docker-compose.yml
- Add www-data to docker group for socket access
2026-07-02 01:50:40 +05:00
F4ilji 3da95da099 fix: use X-CSRF-TOKEN header to match Main.vue pattern
X-XSRF-TOKEN is for Laravel's cookie-based CSRF verification,
but this app uses meta tag CSRF. Main.vue uses X-CSRF-TOKEN
and works correctly.
2026-07-02 01:36:05 +05:00
F4ilji de4fdc2c72 fix: add credentials same-origin to Deploy fetch calls
Without this, session cookie is not sent with fetch() requests,
causing server to redirect to login (returning HTML instead of JSON).
2026-07-02 01:29:05 +05:00
F4ilji b42a733f53 error alert change 2026-07-02 01:24:58 +05:00
F4ilji 8364b220cc feat: add Integration Credentials CRUD and Deploy UI
Integration Credentials:
- Add IntegrationCredential model with encrypted payload
- Add full CRUD: ManageIntegrationCredentialsAction, 4 Tasks, Controller, Requests
- Add migration for integration_credentials table
- Add Vue pages: Index, Create, Edit
- AI tasks now read API keys from DB instead of .env
- Add sidebar menu item

Deploy UI:
- Add DeployTask with full deploy lifecycle management
- Extend DeployController with index, log, history endpoints
- Add Deploy/Index.vue with live polling and deploy log viewer
- Add sidebar menu item and QuickAction for Sveden

UI:
- Add 'key' icon to SidebarNavItem
- Change deploy button color to amber in Main.vue
2026-07-02 01:03:13 +05:00
F4ilji 09bba25993 feat: add dedicated deploy logging to storage/logs/deploy.log
- Add 'deploy' channel in logging.php
- Instrument DeployTask with phpLog() for all state transitions
- Instrument DeploySiteAction with channel('deploy') for triggers, errors, clears
- Logs written to storage/logs/deploy.log (separate from laravel.log)
2026-07-02 00:57:57 +05:00
F4ilji 8715179ffa chore: remove .qwen directory from git tracking
Files were added before .gitignore rule was created.
Now properly ignored via .gitignore.
2026-07-02 00:30:47 +05:00
F4ilji 287b7859c6 chore: cleanup obsolete documentation and rewrite QWEN.md
- Remove nginx-vikon-security.conf (already applied in docker config)
- Remove page-visual-customization.md (outdated)
- Rewrite QWEN.md to compact bootstrapper format (501→95 lines)
- Add .mimocode, .qwen, QWEN.md, AGENTS.md, docs to .gitignore
2026-07-02 00:30:38 +05:00
F4ilji 8bc42f3a9b feat: add UUID, slug generation and defaults to educational program creation
- Auto-generate UUID and unique slug (with education level suffix)
- Set default values for inner_code, learning_forms, direction_study_id
- Slug avoids duplicates by appending numeric suffix
2026-07-02 00:29:38 +05:00
F4ilji 6750ca8f45 fix: add error flash message to Inertia shared props
Add error session key to flash messages so frontend can display
error notifications from session flash data.
2026-07-02 00:29:34 +05:00
F4ilji 2997558a50 refactor: move ByteConverter from Helpers to Services namespace
- Move ByteConverter class to App\Ship\Services
- Update all imports across Filament form blocks
- Update ByteConverterServiceProvider
- Add INTEGRATION_CREDENTIAL_PREFIX to CacheKeys enum
2026-07-02 00:29:29 +05:00
F4ilji 938578d217 fix: handle non-JSON server responses in Sveden upload 2026-07-01 13:58:46 +05:00
F4ilji d83ecc3747 debug: show actual error message in Sveden upload 2026-07-01 13:33:49 +05:00
F4ilji 80b88405f1 fix: resolve Sveden upload permissions and CSRF cookie issue
- Add hostgroup (GID 1000) to Dockerfile so www-data inside container can write to sveden/
- Add credentials: 'same-origin' to fetch calls in Sveden.vue and QuickUpload.vue to send session cookies
2026-07-01 13:10:59 +05:00
F4ilji c9fca0bf29 feat: add Sveden update feature
- SvedenController: upload zip and extract to public/sveden
- UpdateSvedenAction: orchestrates temp file handling and extraction
- ExtractSvedenArchiveTask: validates and extracts sveden/abitur folders with security checks
- Sveden.vue: upload UI with progress indicator
- Added routes: GET/POST /dashboard/sveden
- Added quick action: Обновить Sveden
2026-06-30 16:12:16 +05:00
F4ilji 20f615365b changes 2026-04-17 12:38:39 +05:00
F4ilji 886cac5f95 changes 2026-04-15 16:41:08 +05:00
F4ilji 3669a0d982 changes 2026-04-14 23:10:00 +05:00
F4ilji 9b71f62a04 changes 2026-04-14 22:57:27 +05:00
F4ilji d3433f2a1d changes 2026-04-14 22:52:21 +05:00
F4ilji ad91077263 changes 2026-04-13 20:28:10 +05:00
F4ilji b8c96704c2 changes 2026-04-13 20:23:08 +05:00
F4ilji 2e77978d5c changes 2026-04-13 14:37:57 +05:00
F4ilji 5eb5a7bc06 changes 2026-04-10 10:51:42 +05:00
F4ilji 9e35cf848d changes 2026-04-10 09:01:47 +05:00
F4ilji 1c6f09ed80 changes 2026-04-09 18:17:09 +05:00
F4ilji 0d56ba5a28 changes 2026-04-09 18:09:55 +05:00
F4ilji 419bf112d4 changes 2026-04-09 18:01:10 +05:00
F4ilji 2fc19530ad changes 2026-04-09 17:54:02 +05:00
F4ilji c0babe8277 changes 2026-04-09 17:41:22 +05:00
F4ilji 4aa4b9cadf changes 2026-04-09 17:23:10 +05:00
F4ilji 4bde0b10d0 changes 2026-04-09 13:35:02 +05:00
F4ilji e986940680 changes 2026-04-09 09:09:17 +05:00
F4ilji 169a2f5654 changes 2026-04-08 14:14:38 +05:00
F4iljiandQwen-Coder bd28fc60d7 feat(ssr): вынести обработку SSR ошибок в отдельный файл
- Создан resources/js/ssr/errorHandler.js для логирования SSR ошибок
- SSR ошибки теперь записываются в storage/logs/ssr-errors.log
- Обновлён ssr.js с использованием кастомного error handler
- Добавлен storage/logs в .gitignore

Теперь SSR ошибки не засоряют основные логи приложения

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 22:55:31 +05:00
F4iljiandQwen-Coder d7c6d23a6b fix(ssr): добавить route mixin для SSR совместимости Ziggy
- Создан RouteMixin.js для предоставления route() всем компонентам
- Обновлён ssr.js с правильным импортом Router и созданием route функции
- Исправлен BackButton.vue с проверкой this.route перед использованием
- Добавлена обработка ошибок для route() с fallback на '#'

Решает ошибку: ReferenceError: route is not defined at Proxy.resolvedLink

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 22:41:14 +05:00
F4ilji 3798d01da1 changes 2026-04-07 22:30:06 +05:00
F4iljiandQwen-Coder 438f7df855 fix(ssr): пересобрать SSR бандл, Ziggy routes и Livewire ассеты
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 22:15:45 +05:00
F4iljiandQwen-Coder efec5f3508 refactor(email-tasks): понизить уровень логирования с info до debug для успешных операций
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 22:06:37 +05:00
F4iljiandQwen-Coder 662b722ad4 fix(nginx): добавить mc.yandex.ru в CSP для Яндекс.Метрики
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 20:26:05 +05:00
F4iljiandQwen-Coder 736ea7ae3a fix(nginx): добавить cdn.jsdelivr.net в style-src CSP
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 20:21:40 +05:00
F4iljiandQwen-Coder da0215176a fix(nginx): добавить внешние CDN в CSP (TinyMCE, шрифты) и опубликовать Livewire ассеты
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 20:11:58 +05:00
F4ilji c2bf1127a5 fix 2026-04-07 20:01:27 +05:00
F4iljiandQwen-Coder 3f906cbc20 fix(php): добавить /dev/null в open_basedir для composer
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 20:00:58 +05:00
F4iljiandQwen-Coder 2d5b371247 fix(php): разрешить proc_open для Inertia SSR и расширить open_basedir для composer (включая /root)
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 19:53:33 +05:00
F4ilji 75f9c73dc7 fix(php): добавить /usr/local/bin в open_basedir для работы composer 2026-04-07 18:34:46 +05:00
F4iljiandQwen-Coder 5d946eec3a fix(nginx): разрешить доступ к публичным vendor ассетам (log-viewer)
Проблема: Правило 'location ~ ^/vendor/ { deny all; }' блокировало
доступ к CSS/JS файлам log-viewer в public/vendor/log-viewer/.

Решение:
- location /vendor/ — разрешает доступ к статическим файлам
- Запрещает выполнение .php файлов (security)
- try_files $uri =404 — отдаёт файлы если существуют
- autoindex off — запрещает листинг директорий

Fix: Log Viewer UI не загружался (app.css failed to load)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 18:14:38 +05:00
F4iljiandQwen-Coder b1d964a526 fix(nginx): разрешить доступ к публичным файлам storage через symlink
Проблема: Правило 'location ~ ^/storage/ { deny all; }' блокировало
ВСЕ запросы к /storage/, включая файлы доступные через symlink
public/storage -> storage/app/public.

Решение:
- location /storage/ — разрешает доступ к статическим файлам (изображения, документы)
- Запрещает выполнение .php файлов в storage (security)
- try_files $uri =404 — отдаёт файлы если они существуют
- Запрещает листинг директорий (autoindex off)

Fix: Изображения новостей не загружались (404 ошибка)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-07 18:08:37 +05:00
F4ilji 936b1fb5b0 changes 2026-04-07 17:54:26 +05:00
F4ilji 04adba6682 Changes 2026-04-07 17:47:00 +05:00
F4iljiandQwen-Coder cc20906062 fix(IMAP): исправить загрузку вложений — setFetchBody(true) необходим для структуры
Проблема: setFetchBody(false) не загружал структуру письма,
поэтому getAttachments() возвращал пустой список.

Исправления:
- FetchUnreadEmailsTask: setFetchBody(true) для загрузки структуры (вложения)
- FetchUnreadEmailsTask: конвертация Attribute в строку для subject
- config/imap.php: убраны fetch_body/fetch_flags (контроль на уровне Query)
- Обновлена документация

Fix: "В письме не найдено вложений" ошибка при обработке email

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-06 22:17:21 +05:00
F4iljiandQwen-Coder c115a5c7a7 fix(IMAP): оптимизация обработки email для предотвращения memory exhaustion
Корневая проблема: библиотека webklex/php-imap загружала все тела писем
по умолчанию, что приводило к исчерпанию 256MB памяти при 50+ письмах.

Решение (3 уровня):

Уровень 1 - Конфигурация:
- config/imap.php: добавлены fetch_body=false, fetch_flags=false, fetch_limit=20
- php.ini: увеличен memory_limit с 256M до 1G, добавлены OPcache настройки

Уровень 2 - Оптимизация кода:
- FetchUnreadEmailsTask: setFetchBody(false), лимиты, MemoryAwareTrait
- FetchEmailNewsAction: batch-обработка (10 писем), gc_collect_cycles()
- DownloadAttachmentsTask: lazy load вложений, extension whitelist (security)
- ConnectToImapTask: MemoryAwareTrait для мониторинга

Уровень 3 - Инфраструктура:
- ProcessEmailNewsJob: queue job для асинхронной обработки (3 попытки, 300s timeout)
- FetchEmailNewsCommand: поддержка --async режима
- MemoryAwareTrait: reusable трейт для мониторинга памяти

Security fixes:
- Extension whitelist для предотвращения path traversal
- Убраны реальные email из config/imap.php
- Добавлена проверка на null message объект

Docs:
- README_EMAIL_OPTIMIZATION.md: полная документация по оптимизации

Fixes: Allowed memory size of 268435456 bytes exhausted

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-06 22:08:03 +05:00
F4ilji 4b071d9ef2 Changes 2026-03-27 15:04:43 +05:00
F4ilji 5cf85f08e4 changes 2026-03-23 15:45:49 +05:00
F4ilji c350f712dc changes 2026-03-23 12:05:08 +05:00
F4ilji 1256adba98 changes 2026-03-23 10:50:31 +05:00
F4ilji b1c4f7b054 changes 2026-03-21 15:40:18 +05:00
F4ilji 56d9a0207c changes 2026-03-21 01:02:12 +05:00
F4ilji e58c3088f1 changes 2026-03-21 01:00:31 +05:00
F4ilji d0a106159d changes 2026-03-21 00:53:49 +05:00
F4ilji 836452add6 Changes 2026-03-20 23:35:09 +05:00
F4ilji c80a52ab86 changes 2026-03-20 22:26:10 +05:00
F4ilji a3948c872e Changes 2026-03-20 21:47:02 +05:00
F4ilji ae7b42acbe return LinksReform.js 2026-01-27 14:16:41 +05:00
F4ilji d0cda1dac2 return nginx.conf 2026-01-27 13:41:26 +05:00
F4ilji 7ff0ffc118 change GetEducationalProgramBySlugTask.php 2026-01-26 11:37:04 +05:00
F4ilji 7f93aa185e change LinksReform.js 2026-01-26 10:24:59 +05:00
F4ilji 2b4b97cd7e change EducationSection.vue 2026-01-26 10:13:05 +05:00
F4ilji eeab7cd343 change GetEducationsDataTask.php 2026-01-26 10:01:16 +05:00
F4ilji 9330ed457c change nginx.conf 2026-01-25 22:51:13 +05:00
F4ilji 1ae1b5b047 change helper.php 2026-01-22 14:27:12 +05:00
F4ilji 7ef80e7207 change nginx.conf 2026-01-22 14:03:55 +05:00
F4ilji c4be035890 change nginx.conf 2026-01-22 13:38:34 +05:00
F4ilji 8104d0b6cd change nginx.conf 2026-01-22 13:27:18 +05:00
F4ilji b976814d8b Updated nginx prod configuration 2026-01-21 16:36:48 +05:00
F4ilji afda333c25 Updated nginx prod configuration
Added schedule upload functionality in Filament
Updated .gitignore in public directory
2026-01-18 01:20:16 +05:00
F4ilji 1f61601e05 add cors rules 2026-01-09 19:07:51 +05:00
F4ilji 596e14feee remove jenkins in docker-compose.yml 2026-01-09 14:09:55 +05:00
F4ilji 2d101dcdda feat: Refactor MainController and related tasks to follow Porto architecture
- Moved logic from MainController to dedicated action and task classes.
- Introduced GetMainPageDataAction, GetEducationsDataTask, GetRecentPostsTask, GetUpcomingEventsTask, and GetPageDataTask for better separation of concerns.
- Updated routes to use the new MainController from the Main container.
- Deprecated the old MainController with a clear message for future removal.
- Updated caching mechanisms to use the new task classes.
- Adjusted environment variable access to use config() instead of env() for better practice.
- Added new MainPageResource for structured data response.
- Updated .gitignore to exclude QWEN.md.
- Added Jenkins service to docker-compose for CI/CD integration.
2025-12-16 12:20:01 +05:00
F4ilji f631d12359 Remove License section from README
Removed License section from README.
2025-12-15 21:32:37 +05:00
F4ilji af096e8afb create README.md 2025-12-15 21:30:14 +05:00
F4ilji 8bb7886ef7 Enhance TinyEditor configuration by refining 'test' profile settings and improving paste handling options 2025-12-02 14:23:00 +05:00
F4ilji a61d810d55 Improve image handling and logging in CreateVkPostJob for better error tracking and validation 2025-11-26 13:50:02 +05:00
F4ilji f6d6777d01 Enhance logging in CreateVkPostJob for better debugging and error tracking during post creation 2025-11-25 20:24:34 +05:00
F4ilji 537fa2b0b5 Refactor VkPostPublisher to simplify message formatting and enhance PostListItem layout for better author display 2025-11-25 16:10:50 +05:00
F4ilji a983ab0424 Remove disabled state from VK publication toggle in PostForm 2025-11-22 21:11:19 +05:00
F4ilji 864795749e Update pagination limit and improve author display in PostListItem 2025-11-20 15:58:48 +05:00
F4ilji b470138ccd Merge branch 'master' of https://github.com/F4ilji/ntspi-app 2025-11-20 15:13:18 +05:00
F4ilji 56935abfc0 Update default post status to published and enforce unique group names 2025-11-20 15:05:04 +05:00
F4ilji de4f8e27b8 Update ListBlock label to include exhibitions in InfoSection.vue 2025-11-20 15:04:49 +05:00
F4ilji 480e1a0553 chanage name list for InfoSection 2025-10-11 22:09:38 +05:00
F4ilji 2369eab332 change 2025-10-09 16:30:09 +05:00
F4ilji 6df819f30c disable vk auto post 2025-10-09 16:23:23 +05:00
F4ilji 6570b36cd0 remove regex validation 2025-10-08 18:10:14 +05:00
F4ilji 0a1bbcc297 remove tel validation for WorkersRelationManager.php 2025-10-08 17:52:29 +05:00
F4ilji 2297aab379 Added a check for the emptiness of the array for added programs in Departments/Show.vue 2025-10-08 01:11:52 +05:00
F4ilji 6a279d4942 small changes 2025-10-08 01:08:07 +05:00
F4ilji a65b70a23f delete tel validation 2025-10-05 18:35:44 +05:00
F4ilji d6a5db711d changes 2025-10-05 18:04:32 +05:00
F4ilji f4bfa04690 Refactor Time.vue layout by removing unnecessary margin from schedule and date/time blocks for improved styling consistency. 2025-09-29 16:44:25 +05:00
F4ilji ddefe4995e Remove progress bar from TvSliderBody component and increase slide duration from 9000ms to 15000ms for improved user experience. 2025-09-29 15:45:15 +05:00
F4ilji 4146880905 Add time method to TvController and update routes to include time endpoint for TV functionality. 2025-09-28 12:36:38 +05:00
F4ilji 72af09d93d Add Yandex Metrika integration for tracking; update FindSliderBySlugTask to ensure active slides are filtered correctly based on time; enhance app.blade.php for conditional Yandex Metrika script inclusion based on environment. 2025-09-27 22:35:36 +05:00
F4ilji 02fb2554c1 Enhance FindSliderBySlugTask to filter slides based on current time, ensuring only active slides within the valid time range are retrieved; update SlidesRelationManager to set default and minimum dates for start and end times. Improve Slider component styling for better visibility during loading state. 2025-09-27 14:11:25 +05:00
F4ilji 7c47b36c77 Enhance JournalsRelationManager actions to clear cache before creating, editing, and deleting journal issues, ensuring data consistency and improved performance. 2025-09-16 13:41:28 +05:00
F4ilji 3b55909906 Refactor BackButton component in Show.vue to use route name instead of URL; update PostAuthorsList.vue to correct conditional rendering for authors list display. 2025-09-11 12:37:49 +05:00
F4ilji b905ba4cb5 Refactor VK service URLs in CreateVkPostJob and UpdateVkPostJob to use 'vk.ru' instead of 'vk.com' for consistency; update BasicCarousel component to improve button visibility and touch event handling. 2025-09-09 12:14:58 +05:00
F4ilji ddf9f3a5dc Enhance WorkersRelationManager actions to clear cache before editing and detaching workers; update BasicCarousel button classes for improved visibility on larger screens. 2025-09-08 21:28:46 +05:00
F4ilji 335784231a Add Fruitcake CORS package and update CORS configuration to allow all methods, origins, and headers for improved API accessibility. 2025-09-06 00:17:51 +05:00
F4ilji 29daa1e3aa Update CORS configuration to allow all paths for enhanced API accessibility. 2025-09-05 23:59:40 +05:00
F4ilji 525c9edc65 Refactor VK service URLs to use 'vk.ru' instead of 'vk.com' for consistency; update CORS configuration to allow additional methods and origins, enhancing API security and flexibility. 2025-09-05 23:42:06 +05:00
F4ilji 2a5df7bc59 Merge branch 'master' of https://github.com/F4ilji/ntspi-app 2025-09-05 17:17:35 +05:00
F4ilji ff33023643 Add @gladesinger/vue3-yandex-smartcaptcha dependency, replace vue3-yandex-smartcaptcha with YandexSmartCaptcha in CaptchaBlock component, and update PersonalDataBlock label for compliance with data protection laws. 2025-09-05 17:13:04 +05:00
F4ilji c8ab16aecb Add @gladesinger/vue3-yandex-smartcaptcha dependency, refactor CaptchaBlock component to use YandexSmartCaptcha, and update PersonalDataBlock label for compliance with data protection laws. 2025-09-05 17:12:37 +05:00
F4ilji 5f121430a9 Refactor DesktopNavBar and MainPageNavbar for improved styling and accessibility; update TvSliderBody to enhance text size for better readability. 2025-09-05 09:27:59 +05:00
F4ilji 8b213ee803 Update PHP version requirement to ^8.2 in composer files, refactor User model access control logic, clean up MainSectionResource, enhance EditMainSection to include related pages, modify AppServiceProvider to clear subsection cache, change fallback locale to 'en', and improve DesktopNavBar and BasicFooter components for better accessibility and content accuracy. 2025-09-01 13:48:24 +05:00
F4ilji 3d300ac4d1 Remove migration file for notifications table 2025-09-01 13:47:59 +05:00
F4ilji 478be2e212 Update SlideFactory to set image URL to null, modify SlideResource to make image field optional, and adjust Slider components to display a default image when no URL is provided 2025-08-12 14:50:23 +05:00
F4ilji 06c23edad9 Update example.env 2025-08-11 22:55:58 +05:00
F4ilji cc74e6493a Update example.env 2025-08-11 22:50:34 +05:00
F4ilji e34d49b3f4 add SlideFactory to Slide model and update DatabaseSeeder to include SlideSeeder for improved data seeding 2025-08-11 22:27:15 +05:00
F4ilji be68399ab8 changes Dockerfile 2025-08-11 17:55:07 +05:00
F4ilji 31c929d867 changes 2025-08-11 16:19:23 +05:00
F4ilji ec6e8766e2 Merge branch 'master' of https://github.com/F4ilji/ntspi-app 2025-08-11 16:18:28 +05:00
F4ilji e62b442991 Error correction and preparation of the project for initial deployment 2025-08-11 16:12:10 +05:00
F4ilji abc4319c75 Update example.env 2025-08-11 15:41:59 +05:00
F4ilji 8b2f9e99f0 add 'info' field as an array in DirectionStudy model; update DirectionStudyResource to include tabs for main information and admission details; modify Show.vue to display direction study information; enhance PageItemBlock.vue for better breadcrumb display 2025-07-27 21:33:00 +05:00
F4ilji cb95af3c46 update form handling in Page.vue and Show.vue components to use optional chaining for safer access to form IDs 2025-07-27 13:29:01 +05:00
F4ilji a4bdde6382 refactor educational components and API routes; update form handling in various actions and Vue components, enhance user interface for form display, and improve data retrieval logic in CreateAdmissionPlan job 2025-07-27 12:52:04 +05:00
F4ilji 9a1fcbbf9d update nginx configuration, enhance API route middleware to include 'web', add session middleware to HttpKernel, and improve educational program item and breadcrumbs components for better data display 2025-07-25 17:56:50 +05:00
F4ilji 16e7b9fe46 add time_added field to FilesBlock; update file upload logic to set timestamp and improve file display in FileBlock with additional metadata 2025-07-25 12:36:17 +05:00
F4ilji e403203e72 remove all .DS_Store files from the project 2025-07-22 18:49:44 +05:00
F4ilji 5ba5a101a3 refactor form builder components; simplify structure by removing unnecessary margin classes and enhancing code readability 2025-07-22 18:41:38 +05:00
F4ilji 528a4b24b0 refactor multiple widget controllers; replace caching logic with action classes for data retrieval, enhancing code clarity and maintainability 2025-07-22 18:22:04 +05:00
F4ilji 7a8a6e023d refactor ClientWidgetFormController; replace validation logic with SubmitWidgetFormAction for improved maintainability and clarity 2025-07-22 13:00:18 +05:00
F4ilji eac29d8d1c refactor ClientWidgetContactController and ClientWidgetPageReferenceListController; replace caching logic with action classes for widget data retrieval, enhancing code clarity and maintainability 2025-07-21 22:45:55 +05:00
F4ilji 3cac482409 refactor ClientDepartmentController and ClientFacultyController; replace caching logic with action classes for department and faculty retrieval, enhancing code clarity and maintainability 2025-07-20 18:02:14 +05:00
F4ilji 5179f45ef9 refactoring the InstituteStructure module 2025-07-20 00:37:22 +05:00
F4ilji 4558282da8 refactor ClientAcademicJournalController; replace caching logic with action classes for academic journal retrieval and improve code clarity 2025-07-19 12:49:10 +05:00
F4ilji 1fc793089c refactor PersonController; replace caching logic with GetPersonDataAction for improved maintainability and clarity 2025-07-18 17:10:49 +05:00
F4ilji de7ef99b47 refactor ClientScheduleController; replace complex query logic with ListSchedulesAction for improved maintainability and clarity 2025-07-18 16:35:30 +05:00
F4ilji 18d1501b67 refactor ClientProgramController; replace caching logic with action classes for educational program retrieval and enhance code clarity 2025-07-18 15:57:58 +05:00
F4ilji 839d8edfcd refactor ClientAdditionalEducationController; replace caching logic with action classes for data retrieval and improve code clarity 2025-07-18 14:31:19 +05:00
F4ilji b8235404b8 refactor search functionality; remove deprecated services and implement new action and task classes for improved structure and maintainability 2025-07-18 14:14:10 +05:00
F4ilji 0874d3e6b5 refactor sitemap generation and navigation handling; remove old GenerateSitemap command, update to use tasks for better organization, and enhance navigation data retrieval 2025-07-18 13:07:51 +05:00
F4ilji 1257cd552c refactor schedule module, added new filters 2025-06-27 10:16:10 +05:00
F4ilji 218e5ab8e3 changes 2025-06-25 22:48:01 +05:00
F4ilji efba91653e small fix 2025-06-19 08:23:41 +05:00
F4ilji 97bda3d353 small fix 2025-06-18 23:33:41 +05:00
F4ilji 18b39fa15c Merge pull request #1 from F4ilji/add_news_module
add_vk_module
2025-06-18 20:37:55 +05:00
2478 changed files with 70105 additions and 8316 deletions
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
Signature: 8a477f597d28d172789f06886806bc55
# This file is a cache directory tag created by fontconfig.
# For information about cache directory tags, see:
# http://www.brynosaurus.com/cachedir/
@@ -0,0 +1,54 @@
Graphics Backend used: svp
Passed Tests: 66
Quirky Tests: 36
Failed Tests: 1
Skipped Tests: 6
---Name of the tests that failed---
testDrawInvertN50WithRectangle
---Name of the tests that were Quirky---
testDrawRectWithLine
testDrawRectWithPolygon
testDrawRectWithPolyLine
testDrawRectWithPolyPolygon
testDrawRectWithPolyPolygonB2D
testDrawDiamondWithLine
testComplexDrawTransformedBitmap24bpp
testDashedLine
testLinearGradientBorder
testLinearGradientSteps
testRadialGradient
testRadialGradientOfs
testHalfEllipseWithPolyLine
testHalfEllipseAAWithPolyLine
testHalfEllipseAAWithPolyLineB2D
testHalfEllipseWithPolygon
testHalfEllipseAAWithPolygon
testTextDrawing
testDrawRectangleOnSize1028WithPixel
testDrawRectangleOnSize4096WithPixel
testDrawRectangleOnSize1028WithLine
testDrawRectangleOnSize4096WithLine
testDrawRectangleOnSize1028WithPolyLine
testDrawRectangleOnSize4096WithPolyLine
testDrawRectangleOnSize1028WithPolygon
testDrawRectangleOnSize4096WithPolygon
testDrawRectangleOnSize1028WithPolyLineB2D
testDrawRectangleOnSize4096WithPolyLineB2D
testDrawRectangleOnSize1028WithPolyPolygon
testDrawRectangleOnSize4096WithPolyPolygon
testDrawRectangleOnSize1028WithPolyPolygonB2D
testDrawRectangleOnSize4096WithPolygonPolygonB2D
testDrawOpenPolygonWithPolyLine
testDrawOpenPolygonWithPolygon
testDrawOpenPolygonWithPolyPolygon
testDrawOpenPolygonWithPolyPolygonB2D
---Name of the tests that were Skipped---
testDrawInvertTrackFrameWithRectangle
testDrawBitmap32bpp
testDrawTransformedBitmap32bpp
testDrawBitmapExWithAlpha32bpp
testDrawMask32bpp
testDrawBlend32bpp
Binary file not shown.
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE script:module PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "module.dtd">
<!--
* This file is part of the LibreOffice project.
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* This file incorporates work covered by the following license notice:
*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed
* with this work for additional information regarding copyright
* ownership. The ASF licenses this file to you under the Apache
* License, Version 2.0 (the "License"); you may not use this file
* except in compliance with the License. You may obtain a copy of
* the License at http://www.apache.org/licenses/LICENSE-2.0 .
-->
<script:module xmlns:script="http://openoffice.org/2000/script" script:name="Module1" script:language="StarBasic">REM ***** BASIC *****
Sub Main
End Sub</script:module>
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE library:library PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "library.dtd">
<library:library xmlns:library="http://openoffice.org/2000/library" library:name="Standard" library:readonly="false" library:passwordprotected="false"/>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE library:library PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "library.dtd">
<library:library xmlns:library="http://openoffice.org/2000/library" library:name="Standard" library:readonly="false" library:passwordprotected="false">
<library:element library:name="Module1"/>
</library:library>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE library:libraries PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "libraries.dtd">
<library:libraries xmlns:library="http://openoffice.org/2000/library" xmlns:xlink="http://www.w3.org/1999/xlink">
<library:library library:name="Standard" library:link="false"/>
</library:libraries>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE library:libraries PUBLIC "-//OpenOffice.org//DTD OfficeDocument 1.0//EN" "libraries.dtd">
<library:libraries xmlns:library="http://openoffice.org/2000/library" xmlns:xlink="http://www.w3.org/1999/xlink">
<library:library library:name="Standard" library:link="false"/>
</library:libraries>
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
520(Build:2)
@@ -0,0 +1 @@
1
@@ -0,0 +1,2 @@
<?xml version="1.0"?>
<conf:configuration-backend-db xmlns:conf="http://openoffice.org/extensionmanager/configuration-registry/2010"/>
@@ -0,0 +1,2 @@
<?xml version="1.0"?>
<help:help-backend-db xmlns:help="http://openoffice.org/extensionmanager/help-registry/2010"/>
@@ -0,0 +1 @@
1
@@ -0,0 +1,2 @@
<?xml version="1.0"?>
<conf:configuration-backend-db xmlns:conf="http://openoffice.org/extensionmanager/configuration-registry/2010"/>
@@ -0,0 +1,2 @@
<?xml version="1.0"?>
<help:help-backend-db xmlns:help="http://openoffice.org/extensionmanager/help-registry/2010"/>
@@ -0,0 +1,2 @@
<?xml version="1.0"?>
<conf:configuration-backend-db xmlns:conf="http://openoffice.org/extensionmanager/configuration-registry/2010"/>
@@ -0,0 +1,2 @@
<?xml version="1.0"?>
<help:help-backend-db xmlns:help="http://openoffice.org/extensionmanager/help-registry/2010"/>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<oor:items xmlns:oor="http://openoffice.org/2001/registry" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<item oor:path="/org.openoffice.Office.Common/Misc"><prop oor:name="FirstRun" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Linguistic/ServiceManager/LastFoundSpellCheckers"><prop oor:name="en-US" oor:op="fuse" oor:type="oor:string-list"><value><it>org.openoffice.lingu.MySpellSpellChecker</it></value></prop></item>
<item oor:path="/org.openoffice.Office.Linguistic/ServiceManager/SpellCheckerList"><prop oor:name="en-US" oor:op="fuse" oor:type="oor:string-list"><value><it>org.openoffice.lingu.MySpellSpellChecker</it></value></prop></item>
<item oor:path="/org.openoffice.Office.Logging/Settings"><node oor:name="unopkg" oor:op="replace"><prop oor:name="LogLevel" oor:op="fuse"><value>2147483647</value></prop><prop oor:name="DefaultHandler" oor:op="fuse"><value>com.sun.star.logging.FileHandler</value></prop><node oor:name="HandlerSettings"><prop oor:name="FileURL" oor:op="fuse"><value>$(userurl)/$(loggername).log</value></prop></node><prop oor:name="DefaultFormatter" oor:op="fuse"><value>com.sun.star.logging.PlainTextFormatter</value></prop><node oor:name="FormatterSettings"></node></node></item>
<item oor:path="/org.openoffice.Office.Views/Windows"><node oor:name="SplitWindow0" oor:op="replace"><prop oor:name="Visible" oor:op="fuse"><value xsi:nil="true"/></prop><node oor:name="UserData"><prop oor:name="UserItem" oor:op="fuse" oor:type="xs:string"><value>V1,2,0</value></prop></node><prop oor:name="WindowState" oor:op="fuse"><value xsi:nil="true"/></prop></node></item>
<item oor:path="/org.openoffice.Office.Views/Windows"><node oor:name="SplitWindow1" oor:op="replace"><prop oor:name="Visible" oor:op="fuse"><value xsi:nil="true"/></prop><node oor:name="UserData"><prop oor:name="UserItem" oor:op="fuse" oor:type="xs:string"><value>V1,2,0</value></prop></node><prop oor:name="WindowState" oor:op="fuse"><value xsi:nil="true"/></prop></node></item>
<item oor:path="/org.openoffice.Office.Views/Windows"><node oor:name="SplitWindow2" oor:op="replace"><prop oor:name="Visible" oor:op="fuse"><value xsi:nil="true"/></prop><node oor:name="UserData"><prop oor:name="UserItem" oor:op="fuse" oor:type="xs:string"><value>V1,2,0</value></prop></node><prop oor:name="WindowState" oor:op="fuse"><value xsi:nil="true"/></prop></node></item>
<item oor:path="/org.openoffice.Office.Views/Windows"><node oor:name="SplitWindow3" oor:op="replace"><prop oor:name="Visible" oor:op="fuse"><value xsi:nil="true"/></prop><node oor:name="UserData"><prop oor:name="UserItem" oor:op="fuse" oor:type="xs:string"><value>V1,2,0</value></prop></node><prop oor:name="WindowState" oor:op="fuse"><value xsi:nil="true"/></prop></node></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="DefaultAnchor" oor:op="fuse"><value>1</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="DrawingControl" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="FieldCode" oor:op="fuse"><value>false</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="GraphicObject" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="Note" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="SectionBoundaries" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="ShowBoundaries" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="ShowChangesInMargin" oor:op="fuse"><value>false</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="ShowContentTips" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="ShowInlineTooltips" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="ShowOutlineContentVisibilityButton" oor:op="fuse"><value>false</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="Table" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="TableBoundaries" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="TextBoundaries" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="TextBoundariesFull" oor:op="fuse"><value>false</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="TreatSubOutlineLevelsAsContent" oor:op="fuse"><value>false</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Display"><prop oor:name="UseHeaderFooterMenu" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/NonprintingCharacter"><prop oor:name="Bookmarks" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/NonprintingCharacter"><prop oor:name="Break" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/NonprintingCharacter"><prop oor:name="HiddenCharacter" oor:op="fuse"><value>false</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/NonprintingCharacter"><prop oor:name="HiddenParagraph" oor:op="fuse"><value>false</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/NonprintingCharacter"><prop oor:name="HiddenText" oor:op="fuse"><value>false</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/NonprintingCharacter"><prop oor:name="MetaCharacters" oor:op="fuse"><value>false</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/NonprintingCharacter"><prop oor:name="OptionalHyphen" oor:op="fuse"><value>false</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/NonprintingCharacter"><prop oor:name="ParagraphEnd" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/NonprintingCharacter"><prop oor:name="ProtectedSpace" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/NonprintingCharacter"><prop oor:name="Space" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/NonprintingCharacter"><prop oor:name="Tab" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Update"><prop oor:name="Chart" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Update"><prop oor:name="Field" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Update"><prop oor:name="Link" oor:op="fuse"><value>1</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Zoom"><prop oor:name="DefaultZoom" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Zoom"><prop oor:name="ZoomType" oor:op="fuse"><value>0</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/Content/Zoom"><prop oor:name="ZoomValue" oor:op="fuse"><value>100</value></prop></item>
<item oor:path="/org.openoffice.Office.Writer/FmtAidsAutocomplete"><prop oor:name="EncloseWithCharacters" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Setup/L10N"><prop oor:name="ooLocale" oor:op="fuse"><value>en-US</value></prop></item>
<item oor:path="/org.openoffice.Setup/Office/Factories/org.openoffice.Setup:Factory['com.sun.star.text.TextDocument']"><prop oor:name="ooSetupFactoryWindowAttributes" oor:op="fuse"><value>0,0,800,600;1;,,,;</value></prop></item>
<item oor:path="/org.openoffice.Setup/Office"><prop oor:name="LastCompatibilityCheckID" oor:op="fuse"><value>520(Build:2)</value></prop></item>
<item oor:path="/org.openoffice.Setup/Office"><prop oor:name="OfficeRestartInProgress" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Setup/Office"><prop oor:name="ooSetupInstCompleted" oor:op="fuse"><value>true</value></prop></item>
<item oor:path="/org.openoffice.Setup/Product"><prop oor:name="ooSetupLastVersion" oor:op="fuse"><value>25.2</value></prop></item>
</oor:items>
@@ -0,0 +1,2 @@
<?xml version="1.0"?>
<conf:configuration-backend-db xmlns:conf="http://openoffice.org/extensionmanager/configuration-registry/2010"/>
@@ -0,0 +1,2 @@
<?xml version="1.0"?>
<help:help-backend-db xmlns:help="http://openoffice.org/extensionmanager/help-registry/2010"/>
Regular → Executable
+16 -1
View File
@@ -1,5 +1,6 @@
.env
deploy.sh
.deploy_prev_sha
/node_modules
/vendor
/sourcefiles
@@ -9,4 +10,18 @@ _deploy
/public/abitur
/public/sveden
dump.sql
.idea
.idea
.DS_Store
**/.
.cursor
.cache
hot
/storage/logs
resources/js/ziggy.js
public/vikon_core
.mimocode
.qwen
QWEN.md
AGENTS.md
docs
.phpunit.result.cache
File diff suppressed because one or more lines are too long
Vendored
+179
View File
@@ -0,0 +1,179 @@
pipeline {
agent any
environment {
APP_DIR = '/var/www'
CONTAINER = 'ntspi-php'
}
stages {
stage('Pre-deploy') {
parallel {
stage('Backup Database') {
steps {
dir("${APP_DIR}") {
sh '''
mkdir -p backups
docker exec ntspi-db mysqldump -u admin -psecret ntspi_db | gzip > backups/ntspi_db_$(date +%F_%H%M).sql.gz
find backups -name "*.sql.gz" -mtime +3 -delete
'''
}
}
}
stage('Cleanup Old Files') {
steps {
dir("${APP_DIR}") {
sh 'docker exec ntspi-php find /var/www/storage/app/email_attachments -type f -mtime +3 -delete'
}
}
}
}
}
stage('Maintenance Mode') {
steps {
dir("${APP_DIR}") {
sh 'docker exec ntspi-php php artisan down || true'
}
}
}
stage('Pull from Git') {
steps {
dir("${APP_DIR}") {
sh 'git config --global --add safe.directory ${APP_DIR}'
sh 'DEPLOY_PREV_SHA=$(git rev-parse HEAD) && echo $DEPLOY_PREV_SHA > .deploy_prev_sha'
sh 'git reset --hard'
sh 'git remote set-url origin https://git.occasus.ru/failj/ntspi-app.git'
sh 'git pull origin master'
}
}
}
stage('Install Dependencies') {
steps {
dir("${APP_DIR}") {
sh 'docker exec ntspi-php php -d open_basedir=none /usr/local/bin/composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader'
}
}
}
stage('Database Migrations') {
steps {
dir("${APP_DIR}") {
sh 'docker exec ntspi-php php artisan migrate --force'
sh 'docker exec ntspi-php php artisan dashboard:sync-permissions'
}
}
post {
failure {
dir("${APP_DIR}") {
sh '''
PREV_SHA=$(cat .deploy_prev_sha 2>/dev/null || echo "")
if [ -n "$PREV_SHA" ]; then
echo "Rolling back to $PREV_SHA"
git reset --hard $PREV_SHA
docker exec ntspi-php php artisan migrate:rollback --force || true
docker exec ntspi-php sh -c "php -d open_basedir=none /usr/local/bin/composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader"
docker exec ntspi-php php artisan cache:clear
docker restart ntspi-nginx ntspi-php ntspi-php-queue
fi
'''
}
}
}
}
stage('Build Frontend') {
steps {
dir("${APP_DIR}") {
sh 'docker exec ntspi-php sh -c "npm ci --legacy-peer-deps && npm run build"'
}
}
}
stage('Clear & Warm Caches') {
steps {
dir("${APP_DIR}") {
sh '''
docker exec ntspi-php php artisan cache:clear
docker exec ntspi-php php artisan config:clear
docker exec ntspi-php php artisan route:clear
docker exec ntspi-php php artisan view:clear
docker exec ntspi-php php artisan filament:clear-cached-components
docker exec ntspi-php php artisan filament:optimize-clear
docker exec ntspi-php php artisan optimize
docker exec ntspi-php php artisan icons:cache
docker exec ntspi-php php artisan filament:cache-components
docker exec ntspi-php php artisan filament:optimize
'''
}
}
}
stage('Fix Permissions') {
steps {
dir("${APP_DIR}") {
sh 'docker exec -u root ntspi-php chown -R www-data:www-data storage bootstrap/cache'
sh 'docker exec -u root ntspi-php chmod -R 775 storage bootstrap/cache'
}
}
}
stage('Restart Services') {
steps {
dir("${APP_DIR}") {
sh '''
docker restart ntspi-nginx ntspi-php ntspi-php-queue
sleep 5
docker exec ntspi-php php artisan inertia:stop-ssr || true
sleep 2
docker exec ntspi-php php artisan inertia:start-ssr || true
'''
}
}
}
stage('Health Check') {
steps {
dir("${APP_DIR}") {
sh '''
for i in $(seq 1 30); do
if docker exec ntspi-php php artisan about > /dev/null 2>&1; then
echo "App is ready"
break
fi
echo "Waiting for app... ($i/30)"
sleep 1
done
'''
}
}
}
stage('Enable App') {
steps {
dir("${APP_DIR}") {
sh 'docker exec ntspi-php php artisan up'
}
}
}
}
post {
success {
echo "Deploy completed successfully"
dir("${APP_DIR}") {
sh 'rm -f .deploy_prev_sha'
}
}
failure {
echo "Deploy failed. Check build logs in Jenkins."
dir("${APP_DIR}") {
sh 'docker exec ntspi-php php artisan up || true'
sh 'rm -f .deploy_prev_sha'
}
}
}
}
Executable
+118
View File
@@ -0,0 +1,118 @@
***
# Corporate Digital University System (NTSPI)
A comprehensive web platform designed for the **Nizhny Tagil State Social Pedagogical Institute (NTSPI)**. This system serves as a full-featured replacement for the legacy website, providing tools for content management, educational program administration, admission campaigns, and institutional structure management.
## 🚀 Tech Stack
### Frontend
* **Framework:** Vue.js 3 (Composition API)
* **Adapter:** Inertia.js (Monolith structure with SPA feel)
* **Styling:** Tailwind CSS
* **Build Tool:** Vite
### Backend
* **Core:** PHP 8.2 / Laravel
* **Admin Panel:** Filament PHP (CMS & Resource Management)
* **Database:** MySQL 8.0
* **Caching & Queues:** Redis
* **Server:** Nginx + PHP-FPM
### Infrastructure
* **Containerization:** Docker & Docker Compose
* **SSL:** Certbot (Let's Encrypt)
---
## 🏗 Architecture
The backend follows the **Porto Architecture Pattern**, organizing code into logical modular units to ensure scalability and maintainability.
* **Ship Layer:** Low-level code, parent classes, and configuration.
* **Containers Layer:** Encapsulates business logic into modular containers (e.g., `AdditionalEducation`, `Article`, `Event`, `User`, `Schedule`, etc.).
* **Request Lifecycle:** Route -> Controller -> Action -> Task -> UI.
---
## ✨ Key Features
* **Content Management (CMS):** Advanced management of News, Articles, and Events with media galleries and status workflows (Draft, Published, Archived).
* **Institute Structure:** Hierarchical management of Faculties, Departments (Kafedras), Divisions, and Staff profiles.
* **Educational Programs:** Catalog of study programs, admission plans, and entrance exam requirements.
* **Admissions Management:** Tools to manage "Admission Campaigns" (Priemnaya Kampaniya), enabling dynamic updates to student intake numbers and rules.
* **Role-Based Access Control (RBAC):** Powered by **Filament Shield**. Granular permissions allowing different departments (Deans, Press Service, HR) to manage their specific sections independently.
* **Dynamic Routing:** Custom pages and navigation structures managed directly via the Database.
* **Cross-Model Search:** Unified global search across News, Pages, Events, and Programs using standard Eloquent models.
---
## 🛠 Installation & Local Development
The project includes a Docker environment for easy deployment.
### 1. Clone the Repository
```bash
git clone https://github.com/F4ilji/ntspi-app.git
cd ntspi-app
```
### 2. Configure Environment
Create the environment file from the example.
```bash
cp example.env .env
```
### 3. Create Directories & Set Permissions
Ensure Laravel storage directories exist and have write permissions.
```bash
mkdir -p storage/{app,logs,framework/{cache,sessions,views}}
sudo chown -R www-data:www-data storage
sudo chmod -R 775 storage
```
### 4. Start Docker Containers
Launch Nginx, MySQL, Redis, and the App container.
```bash
docker compose up --build -d
```
### 5. Install Dependencies
Enter the PHP container to run setup commands:
```bash
docker exec -it ntspi-php bash
```
Inside the container, run:
```bash
# Backend dependencies
composer install
# Database migrations
php artisan migrate
# Frontend dependencies
npm i
# Build assets
npm run build
```
### 6. Initialize System Data
Set up user roles and seed the database with initial data.
```bash
# Initialize permissions/roles (Critical for Filament Shield)
php artisan roles:init-roles
# Seed dummy data (Optional)
php artisan db:seed
```
---
## 📂 Project Structure Overview
* `_docker/`: Docker configuration files (Nginx, PHP, MySQL).
* `app/Containers/`: Business logic modules (Porto pattern).
* `resources/js/`: Vue.js components, Pages, and shared UI assets.
* `routes/`: Web and API routes (including dynamic DB-based routing logic).
BIN
View File
Binary file not shown.
Regular → Executable
+38 -15
View File
@@ -1,6 +1,9 @@
FROM php:8.2-fpm
# Установка системных зависимостей
# Устанавливаем рабочую директорию
WORKDIR /var/www
# Установка системных зависимостей и инструментов в одном слое
RUN apt-get update && apt-get install -y \
apt-utils \
libpq-dev \
@@ -21,15 +24,26 @@ RUN apt-get update && apt-get install -y \
libicu-dev \
g++ \
default-mysql-client \
&& docker-php-ext-configure gd --with-webp --with-jpeg \
&& docker-php-ext-install gd pdo_mysql bcmath zip intl \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Установка Node.js (актуальная LTS-версия)
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
# LibreOffice для конвертации DOC в DOCX
libreoffice-common \
libreoffice-writer \
# Kerberos для IMAP
libkrb5-dev \
# Установка Node.js (актуальная LTS-версия 18)
&& curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
&& apt-get install -y nodejs \
&& npm install -g npm
&& npm install -g npm \
# Очистка кэша для уменьшения размера образа
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Установка install-php-extensions
ADD https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
RUN chmod +x /usr/local/bin/install-php-extensions
# Установка расширений PHP
RUN docker-php-ext-configure gd --with-webp --with-jpeg \
&& docker-php-ext-install -j$(nproc) gd pdo_mysql bcmath zip intl \
&& install-php-extensions imap
# Установка Composer
ENV COMPOSER_ALLOW_SUPERUSER=1
@@ -37,7 +51,19 @@ RUN curl -sS https://getcomposer.org/installer | php -- \
--filename=composer \
--install-dir=/usr/local/bin
# Копирование конфигураций
# Создание необходимых директорий для Laravel
RUN mkdir -p storage/framework/views storage/framework/sessions storage/framework/cache storage/logs bootstrap/cache
# Добавляем www-data в группу с GID=1000 (совпадает с GID хост-пользователя)
RUN groupadd -g 1000 hostgroup 2>/dev/null || true \
&& usermod -aG hostgroup www-data
# Установка правильных прав доступа
RUN chown -R www-data:www-data storage bootstrap/cache \
&& chmod -R 775 storage bootstrap/cache
# Копирование конфигураций PHP и Supervisor
COPY ./_docker/app/php.ini /usr/local/etc/php/conf.d/php.ini
COPY ./_docker/supervisor/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
@@ -47,12 +73,9 @@ RUN chmod 0644 /etc/cron.d/laravel-cron \
&& crontab /etc/cron.d/laravel-cron \
&& touch /var/log/cron.log
# Рабочая директория
WORKDIR /var/www
# Запуск Supervisor (управляет PHP-FPM и Inertia SSR)
# Запуск Supervisor (управляет PHP-FPM, Inertia SSR, и worker-ами)
CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/supervisord.conf"]
# Открываем порты
EXPOSE 9000
EXPOSE 13714
EXPOSE 13714
Regular → Executable
View File
Regular → Executable
+53 -1
View File
@@ -1,4 +1,56 @@
post_max_size = 128M
upload_max_filesize = 128M
memory_limit = 256M
max_file_uploads = 50
; OPTIMIZATION: Увеличен лимит памяти для обработки больших IMAP-сообщений
; Предыдущая ошибка: 256MB было недостаточно для загрузки всех писем
; Теперь: 1GB позволяет обрабатывать письма batch-ами по 10 штук
memory_limit = 1G
request_terminate_timeout = 300
max_execution_time = 120
max_input_time = 120
; OPTIMIZATION: Настройки OPcache для производительности
opcache.enable = 1
opcache.memory_consumption = 128
opcache.max_accelerated_files = 10000
opcache.revalidate_freq = 2
; OPTIMIZATION: Настройки сессий
session.gc_maxlifetime = 1440
session.cookie_lifetime = 0
; ============================================
; SECURITY: open_basedir restriction
; ОТКЛЮЧЕНО — блокирует работу Composer, Artisan, LibreOffice
; ============================================
; open_basedir = /var/www:/tmp:/root/.config:/usr/local/bin:/usr/local/lib/php
; ============================================
; SECURITY: Запрет опасных функций
; curl_exec и shell_exec оставлены — используются приложением (VK API, LibreOffice конвертация)
; proc_open и popen оставлены — требуются для Inertia SSR (Symfony Process)
; ============================================
disable_functions = passthru,system,parse_ini_file,show_source,highlight_file,pcntl_exec,leak,apache_child_terminate,posix_kill,posix_mkfifo,posix_setpgid,posix_setsid,posix_setuid
; ============================================
; SECURITY: Запрет удалённого включения файлов
; Предотвращает RFI (Remote File Inclusion) атаки
; ============================================
allow_url_include = Off
allow_url_fopen = On
; ============================================
; SECURITY: Защищённые сессии
; ============================================
session.cookie_httponly = 1
session.cookie_secure = 1
session.use_strict_mode = 1
session.use_only_cookies = 1
session.name = __Secure-NTSPISESSID
; ============================================
; SECURITY: Скрыть версию PHP из заголовков
; ============================================
expose_php = Off
+24
View File
@@ -0,0 +1,24 @@
# _docker/jenkins/Dockerfile
FROM jenkins/jenkins:lts-jdk17
# Переключаемся на пользователя root для установки пакетов
USER root
# Устанавливаем Docker CLI и другие утилиты
RUN apt-get update && apt-get install -y --no-install-recommends lsb-release curl && \
curl -fsSLo /usr/share/keyrings/docker-archive-keyring.asc https://download.docker.com/linux/debian/gpg && \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.asc] https://download.docker.com/linux/debian $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list && \
apt-get update && \
apt-get install -y --no-install-recommends docker-ce-cli && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Устанавливаем Docker Compose v2 (как плагин)
RUN mkdir -p /usr/local/lib/docker/cli-plugins && \
curl -SL "https://github.com/docker/compose/releases/download/v2.2.3/docker-compose-linux-$(uname -m)" -o /usr/local/lib/docker/cli-plugins/docker-compose && \
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
# Возвращаемся к пользователю jenkins
USER jenkins
Regular → Executable
View File
+10
View File
@@ -25,7 +25,17 @@ server {
try_files $uri /index.php?$args; # Обработка запросов
}
# ========================================================================
# VIKON MODULE SECURITY — Block executable files in module directories
# MUST be placed BEFORE location ~ \.php$ to take effect
# ========================================================================
# Block PHP and other server-side scripts in sveden/abitur
location ~ ^/(sveden|abitur)/.*\.(php|php3|php4|php5|php7|php8|phps|phtml|pl|py|pyc|cgi|sh|bash|bat|cmd|exe|com|ps1|psm1|rb|asp|aspx|jsp|cfm)$ {
deny all;
return 403;
access_log /var/log/nginx/blocked_module_scripts.log;
}
location /sveden/ {
alias /var/www/public/sveden/;
+122 -7
View File
@@ -50,17 +50,40 @@ server {
root /var/www/public;
# Security headers
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
add_header X-XSS-Protection "1; mode=block";
add_header Referrer-Policy "strict-origin-when-cross-origin";
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload";
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header Content-Security-Policy "default-src 'self' https:; script-src 'self' 'unsafe-inline' 'unsafe-eval' https:; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https: blob:; font-src 'self' data: https:; connect-src 'self' https:; worker-src 'self' blob:; frame-ancestors 'self';" always;
location ^~ /jenkins/ {
proxy_pass http://jenkins:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 90;
proxy_max_temp_file_size 0;
proxy_redirect http:// https://;
}
location / {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Origin "https://www.ntspi.ru" always;
try_files $uri /index.php?$args;
}
# ========================================================================
# VIKON MODULE SECURITY — Block executable files in module directories
# MUST be placed BEFORE location ~ \.php$ to take effect
# ========================================================================
location ~ ^/(sveden|abitur)/.*\.(php|php3|php4|php5|php7|php8|phps|phtml|pl|py|pyc|cgi|sh|bash|bat|cmd|exe|com|ps1|psm1|rb|asp|aspx|jsp|cfm)$ {
deny all;
return 403;
access_log /var/log/nginx/blocked_module_scripts.log;
}
location /sveden/ {
alias /var/www/public/sveden/;
index index.html;
@@ -85,7 +108,74 @@ server {
return 301 /abitur/;
}
location ~ \.php$ {
location /vsoko/ {
alias /var/www/public/vsoko/;
index index.html;
try_files $uri $uri/ /vsoko/index.html;
expires epoch;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
}
location = /vsoko {
return 301 /vsoko/;
}
# ============================================
# SECURITY: Запрет доступа к чувствительным файлам
# ============================================
# Запрет доступа к .env, .git и всем скрытым файлам
location ~ /\. {
deny all;
return 404;
}
# Запрет доступа к composer, package, lock, readme файлам
location ~* (composer\.(json|lock)|package\.(json|lock)|\.md|\.txt|\.log)$ {
deny all;
return 404;
}
# Разрешаем доступ к публичным vendor ассетам (log-viewer, и т.д.)
# Это статические файлы в public/vendor/
location /vendor/ {
# Запрещаем выполнение PHP файлов
location ~ \.php$ {
deny all;
return 404;
}
# Отдаём статические файлы
try_files $uri =404;
autoindex off;
}
# Разрешаем доступ к публичным файлам storage (через symlink public/storage)
# Это должны быть ТОЛЬКО изображения, документы и т.д. — НЕ PHP файлы
location /storage/ {
# Запрещаем выполнение PHP файлов в storage
location ~ \.php$ {
deny all;
return 404;
}
# Разрешаем доступ к статическим файлам
try_files $uri =404;
# Запрещаем листинг директорий
autoindex off;
}
# Запрет прямого доступа к internal storage paths (в обход symlink)
location ~ ^/storage/app/(?!public) {
deny all;
return 404;
}
# ============================================
# VIKON: OAuth callback redirect (index.php)
# ============================================
location = /vikon_core/update/index.php {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass app:9000;
@@ -99,6 +189,31 @@ server {
fastcgi_param HTTPS On;
}
# ============================================
# PHP-FPM: ТОЛЬКО /index.php как entry point
# ============================================
location ~ \.php$ {
# КРИТИЧНО: Разрешаем ТОЛЬКО /index.php
# Любой другой .php файл (например, shell.php в uploads) — 403
if ($uri !~ ^/index\.php$) {
return 403;
}
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass app:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
fastcgi_read_timeout 600;
fastcgi_send_timeout 600;
fastcgi_param REQUEST_SCHEME https;
fastcgi_param HTTPS On;
}
location ~ /\.ht {
deny all;
}
+22 -1
View File
@@ -20,11 +20,32 @@ server {
root /var/www/public;
location ^~ /jenkins/ {
proxy_pass http://jenkins:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 90;
proxy_max_temp_file_size 0;
proxy_redirect http:// https://;
}
location / {
add_header Access-Control-Allow-Origin *; # Заголовок для CORS
try_files $uri /index.php?$args; # Обработка запросов
}
# ========================================================================
# VIKON MODULE SECURITY — Block executable files in module directories
# MUST be placed BEFORE location ~ \.php$ to take effect
# ========================================================================
location ~ ^/(sveden|abitur)/.*\.(php|php3|php4|php5|php7|php8|phps|phtml|pl|py|pyc|cgi|sh|bash|bat|cmd|exe|com|ps1|psm1|rb|asp|aspx|jsp|cfm)$ {
deny all;
return 403;
access_log /var/log/nginx/blocked_module_scripts.log;
}
location /sveden/ {
alias /var/www/public/sveden/;
index index.html;
View File
BIN
View File
Binary file not shown.
-223
View File
@@ -1,223 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Containers\AdditionalEducation\Models\AdditionalEducation;
use App\Containers\AppStructure\Models\Page;
use App\Containers\Article\Enums\PostStatus;
use App\Containers\Article\Models\Post;
use App\Containers\Education\Models\EducationalProgram;
use App\Containers\Event\Models\Event;
use App\Containers\InstituteStructure\Models\Department;
use App\Containers\InstituteStructure\Models\Division;
use App\Containers\InstituteStructure\Models\Faculty;
use App\Containers\User\Models\User;
use App\Ship\Enums\Education\EducationalProgramStatus;
use Spatie\Sitemap\Sitemap;
use Spatie\Sitemap\Tags\Url;
use Illuminate\Console\Command;
class GenerateSitemap extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'sitemap:generate';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Генерирует карту сайта';
/**
* Execute the console command.
*/
public function handle()
{
$sitemap = Sitemap::create();
// Генерация карты сайта для всех моделей
$this->generatePages($sitemap);
$this->generatePosts($sitemap);
$this->generateDivisions($sitemap);
$this->generateEducationPrograms($sitemap); // Добавляем генерацию для EducationProgram
$this->generateEvents($sitemap); // Добавляем генерацию для Event
$this->generateAdditionalEducations($sitemap); // Добавляем генерацию для AdditionalEducation
$this->generateFaculties($sitemap); // Добавляем генерацию для Faculty
$this->generateDepartments($sitemap); // Добавляем генерацию для Department
$this->generateUsers($sitemap); // Добавляем генерацию для User
// Сохраняем карту сайта в файл
$sitemap->writeToFile(public_path('sitemap.xml'));
}
protected function generatePages(Sitemap $sitemap)
{
$pages = Page::query()
->where('is_visible', true)
->where('code', 200)
->where('path', '!=', null)
->where('is_url', false)
->where('title', '!=', null)
->where('searchable', true)
->get();
$this->addUrlsToSitemap($sitemap, $pages, function($page) {
return [
'route' => 'page.view',
'params' => ['path' => $page->path],
'lastModificationDate' => $page->updated_at,
'priority' => 0.5,
];
});
}
protected function generatePosts(Sitemap $sitemap)
{
$posts = Post::query()
->where('status', PostStatus::PUBLISHED)
->get();
$this->addUrlsToSitemap($sitemap, $posts, function($post) {
return [
'route' => 'client.post.show',
'params' => ['slug' => $post->slug],
'lastModificationDate' => $post->updated_at,
'priority' => 0.5,
];
});
}
protected function generateDivisions(Sitemap $sitemap)
{
$divisions = Division::query()
->where('is_active', true)
->get();
$this->addUrlsToSitemap($sitemap, $divisions, function($division) {
return [
'route' => 'client.division.show',
'params' => ['slug' => $division->slug],
'lastModificationDate' => $division->updated_at,
'priority' => 0.5,
];
});
}
protected function generateEducationPrograms(Sitemap $sitemap)
{
$programs = EducationalProgram::query()
->where('status', EducationalProgramStatus::PUBLISHED) // Пример условия для активных программ
->get();
$this->addUrlsToSitemap($sitemap, $programs, function($program) {
return [
'route' => 'client.program.show',
'params' => ['slug' => $program->slug],
'lastModificationDate' => $program->updated_at,
'priority' => 0.5,
];
});
}
protected function generateEvents(Sitemap $sitemap)
{
$now = now()->toDateString(); // Текущая дата
$events = Event::query()
->where('event_date_end', '>=', $now) // Только актуальные события
->get();
$this->addUrlsToSitemap($sitemap, $events, function($event) {
return [
'route' => 'client.event.show',
'params' => ['slug' => $event->slug],
'lastModificationDate' => $event->updated_at,
'priority' => 0.5,
];
});
}
protected function generateAdditionalEducations(Sitemap $sitemap)
{
$educations = AdditionalEducation::query()
->where('is_active', true) // Пример условия для активных программ
->get();
$this->addUrlsToSitemap($sitemap, $educations, function($education) {
return [
'route' => 'client.additionalEducation.show',
'params' => ['slug' => $education->slug],
'lastModificationDate' => $education->updated_at,
'priority' => 0.5,
];
});
}
protected function generateFaculties(Sitemap $sitemap)
{
$faculties = Faculty::query()
->where('is_active', true) // Пример условия для активных факультетов
->get();
$this->addUrlsToSitemap($sitemap, $faculties, function($faculty) {
return [
'route' => 'client.faculty.show',
'params' => ['slug' => $faculty->slug],
'lastModificationDate' => $faculty->updated_at,
'priority' => 0.5,
];
});
}
protected function generateDepartments(Sitemap $sitemap)
{
$departments = Department::query()
->where('is_active', true) // Пример условия для активных кафедр
->get();
$this->addUrlsToSitemap($sitemap, $departments, function($department) {
return [
'route' => 'client.department.show',
'params' => ['facultySlug' => $department->faculty->slug, 'departmentSlug' => $department->slug],
'lastModificationDate' => $department->updated_at,
'priority' => 0.5,
];
});
}
protected function generateUsers(Sitemap $sitemap)
{
$users = User::query()
->whereHas('userDetail', function ($q) {
$q->where('is_only_worker', false);
})
->get();
$this->addUrlsToSitemap($sitemap, $users, function($user) {
return [
'route' => 'client.person.show',
'params' => ['slug' => $user->slug],
'lastModificationDate' => $user->updated_at,
'priority' => 0.5,
];
});
}
protected function addUrlsToSitemap(Sitemap $sitemap, $items, callable $callback)
{
foreach ($items as $item) {
$urlData = $callback($item);
$url = route($urlData['route'], $urlData['params']);
$sitemap->add(Url::create($url)
->setLastModificationDate($urlData['lastModificationDate'])
->setPriority($urlData['priority']));
}
}
}
View File
View File
View File
Regular → Executable
View File
@@ -0,0 +1,38 @@
<?php
namespace App\Containers\AdditionalEducation\Actions;
use App\Containers\AdditionalEducation\Data\Resources\AdditionalEducationResource;
use App\Containers\AdditionalEducation\Tasks\FindAdditionalEducationBySlugTask;
use App\Ship\Contracts\SeoServiceInterface;
use Illuminate\Http\Request;
class GetAdditionalEducationBySlugAction
{
public function __construct(
readonly SeoServiceInterface $seoPageProvider,
readonly FindAdditionalEducationBySlugTask $findAdditionalEducationBySlugTask,
) {}
public function run(string $slug, Request $request): array
{
$additionalEducationModel = $this->findAdditionalEducationBySlugTask->run($slug);
$seo = $this->seoPageProvider->getSeoForModel($additionalEducationModel);
$settingsPage = $request->attributes->get('settings_page') ?? [];
if (array_key_exists('form', $settingsPage)) {
$form = $settingsPage['form'];
} else {
$form = null;
}
$additionalEducation = new AdditionalEducationResource($additionalEducationModel);
return compact(
'additionalEducation',
'seo',
'form',
);
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Containers\AdditionalEducation\Actions;
use App\Containers\AdditionalEducation\Tasks\GetAllAdditionalEducationCategoriesPreviewTask;
use App\Containers\AdditionalEducation\Tasks\GetDirectionAdditionalEducationsTask;
use App\Containers\AdditionalEducation\Tasks\GetFilteredAdditionalEducationCategoriesTask;
use App\Containers\AdditionalEducation\Tasks\BuildFormsEducationArrayTask;
use App\Containers\AdditionalEducation\Tasks\BuildAdditionalEducationFiltersTask;
use App\Ship\Actions\Action;
use App\Ship\Contracts\SeoServiceInterface;
use Illuminate\Http\Request;
class GetAllAdditionalEducationsAction
{
public function __construct(
readonly SeoServiceInterface $seoPageProvider,
readonly GetDirectionAdditionalEducationsTask $getDirectionAdditionalEducationsTask,
readonly GetFilteredAdditionalEducationCategoriesTask $getFilteredAdditionalEducationCategoriesTask,
readonly GetAllAdditionalEducationCategoriesPreviewTask $getAllAdditionalEducationCategoriesPreviewTask,
readonly BuildFormsEducationArrayTask $buildFormsEducationArrayTask,
readonly BuildAdditionalEducationFiltersTask $buildAdditionalEducationFiltersTask,
) {}
public function run(Request $request): array
{
$directionAdditionalEducations = $this->getDirectionAdditionalEducationsTask->run($request);
$additionalEducations = $this->getFilteredAdditionalEducationCategoriesTask->run($request);
$categories = $this->getAllAdditionalEducationCategoriesPreviewTask->run();
$forms_education = $this->buildFormsEducationArrayTask->run();
$seo = $this->seoPageProvider->getSeoForCurrentPage();
$filters = $this->buildAdditionalEducationFiltersTask->run($request);
return compact(
'directionAdditionalEducations',
'additionalEducations',
'filters',
'forms_education',
'categories',
'seo'
);
}
}
View File
View File
View File
+4 -2
View File
@@ -5,13 +5,15 @@ namespace App\Containers\AdditionalEducation\Models;
use App\Ship\Enums\Education\FormEducation;
use App\Ship\Models\Model;
use App\Ship\Models\Seo;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use App\Ship\Traits\HasContainerFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphOne;
class AdditionalEducation extends Model
{
use HasFactory;
use HasContainerFactory;
protected static string $factory = \Database\Factories\AdditionalEducationFactory::class;
protected $guarded = false;
+4 -2
View File
@@ -3,14 +3,16 @@
namespace App\Containers\AdditionalEducation\Models;
use App\Ship\Models\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use App\Ship\Traits\HasContainerFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class AdditionalEducationCategory extends Model
{
use HasFactory;
use HasContainerFactory;
protected static string $factory = \Database\Factories\AdditionalEducationCategoryFactory::class;
protected $guarded = false;
+4 -2
View File
@@ -3,12 +3,14 @@
namespace App\Containers\AdditionalEducation\Models;
use App\Ship\Models\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use App\Ship\Traits\HasContainerFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
class DirectionAdditionalEducation extends Model
{
use HasFactory;
use HasContainerFactory;
protected static string $factory = \Database\Factories\DirectionAdditionalEducationFactory::class;
protected $guarded = false;
@@ -0,0 +1,46 @@
<?php
namespace App\Containers\AdditionalEducation\Tasks;
use App\Containers\AdditionalEducation\Data\Resources\AdditionalEducationCategoryResource;
use App\Containers\AdditionalEducation\Models\AdditionalEducationCategory;
use App\Ship\Enums\Education\FormEducation;
use Illuminate\Http\Request;
class BuildAdditionalEducationFiltersTask
{
public function run(Request $request): array
{
$categoriesContent = [];
if ($request->category) {
foreach ((array)$request->category as $item) {
$categoriesContent[$item] = (new GetFilteredAdditionalEducationCategoriesTask())->run(new Request(['category' => $item]))->first();
}
}
$forms_education = array_reduce(
FormEducation::cases(),
fn ($acc, $case) => $acc + [$case->name => $case->getLabel()],
[]
);
return [
'direction_filter' => [
'type' => 'direction',
'value' => $request->input('direction'),
'param' => 'direction'
],
'form_education_filter' => [
'type' => 'form',
'value' => $request->input('form'),
'param' => 'form'
],
'category_filter' => [
'type' => 'category',
'value' => $request->input('category'),
'param' => 'category',
'content' => $categoriesContent,
],
];
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Containers\AdditionalEducation\Tasks;
use App\Ship\Enums\Education\FormEducation;
class BuildFormsEducationArrayTask
{
public function run(): array
{
return array_reduce(
FormEducation::cases(),
fn ($acc, $case) => $acc + [$case->name => $case->getLabel()],
[]
);
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Containers\AdditionalEducation\Tasks;
use App\Containers\AdditionalEducation\Models\AdditionalEducation;
use App\Ship\Enums\CacheKeys;
use App\Ship\Tasks\Task;
use Illuminate\Support\Facades\Cache;
class FindAdditionalEducationBySlugTask
{
public function run(string $slug)
{
return Cache::remember(
CacheKeys::ADDITIONAL_EDUCATIONAL_PROGRAM_PREFIX->value . $slug,
now()->addDay(),
fn() => AdditionalEducation::with('category.direction')->where('slug', $slug)->firstOrFail()
);
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Containers\AdditionalEducation\Tasks;
use App\Containers\AdditionalEducation\Data\Resources\AdditionalEducationCategoryPreviewResource;
use App\Containers\AdditionalEducation\Models\AdditionalEducationCategory;
use App\Ship\Enums\CacheKeys;
use App\Ship\Tasks\Task;
use Illuminate\Support\Facades\Cache;
class GetAllAdditionalEducationCategoriesPreviewTask
{
public function run()
{
return Cache::remember(
CacheKeys::ADDITIONAL_EDUCATIONAL_PROGRAMS_PREFIX->value . 'categories',
now()->addWeek(),
function () {
return AdditionalEducationCategoryPreviewResource::collection(
AdditionalEducationCategory::query()
->where('is_active', true)
->has('additionalEducations')
->get()
);
}
);
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Containers\AdditionalEducation\Tasks;
use App\Containers\AdditionalEducation\Data\Resources\DirectionAdditionalEducationResource;
use App\Containers\AdditionalEducation\Models\DirectionAdditionalEducation;
use App\Ship\Enums\CacheKeys;
use App\Ship\Tasks\Task;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class GetDirectionAdditionalEducationsTask
{
public function run(Request $request)
{
$cacheKey = md5(serialize($request->all()));
return Cache::remember(
CacheKeys::ADDITIONAL_EDUCATIONAL_PROGRAMS_PREFIX->value . 'directions_' . $cacheKey,
now()->addDay(),
function () {
return DirectionAdditionalEducationResource::collection(
DirectionAdditionalEducation::query()
->where('is_active', true)
->whereHas('additionalEducationCategories', fn ($q) => $q->whereHas('additionalEducations'))
->get()
);
}
);
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Containers\AdditionalEducation\Tasks;
use App\Containers\AdditionalEducation\Data\Resources\AdditionalEducationCategoryResource;
use App\Containers\AdditionalEducation\Models\AdditionalEducationCategory;
use App\Ship\Enums\CacheKeys;
use App\Ship\Enums\Education\FormEducation;
use App\Ship\Tasks\Task;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class GetFilteredAdditionalEducationCategoriesTask
{
public function run(Request $request)
{
$cacheKey = md5(serialize($request->all()));
return Cache::remember(
CacheKeys::ADDITIONAL_EDUCATIONAL_PROGRAMS_PREFIX->value . $cacheKey,
now()->addDay(),
function () use ($request) {
$query = AdditionalEducationCategory::query()
->has('additionalEducations')
->withActivePrograms()
->where('is_active', true);
if ($request->has('form')) {
$formValue = FormEducation::fromName($request->form)->value;
$query->whereHas('additionalEducations', fn ($q) => $q->where('form_education', $formValue))
->with(['additionalEducations' => fn ($q) => $q->where('form_education', $formValue)]);
}
if ($request->has('category')) {
$slugs = is_array($request->category) ? $request->category : [$request->category];
$query->whereIn('slug', $slugs);
}
if ($request->has('direction')) {
$query->whereHas('direction', fn($q) => $q->where('slug', $request->direction));
}
return AdditionalEducationCategoryResource::collection($query->get());
}
);
}
}
@@ -2,160 +2,31 @@
namespace App\Containers\AdditionalEducation\UI\WEB\Controllers;
use App\Containers\AdditionalEducation\Data\Resources\AdditionalEducationCategoryPreviewResource;
use App\Containers\AdditionalEducation\Data\Resources\AdditionalEducationCategoryResource;
use App\Containers\AdditionalEducation\Data\Resources\AdditionalEducationResource;
use App\Containers\AdditionalEducation\Data\Resources\DirectionAdditionalEducationResource;
use App\Containers\AdditionalEducation\Models\AdditionalEducation;
use App\Containers\AdditionalEducation\Models\AdditionalEducationCategory;
use App\Containers\AdditionalEducation\Models\DirectionAdditionalEducation;
use App\Ship\Contracts\SeoServiceInterface;
use App\Containers\AdditionalEducation\Actions\GetAllAdditionalEducationsAction;
use App\Containers\AdditionalEducation\Actions\GetAdditionalEducationBySlugAction;
use App\Ship\Controllers\Controller;
use App\Ship\Enums\CacheKeys;
use App\Ship\Enums\Education\FormEducation;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Inertia\Inertia;
class ClientAdditionalEducationController extends Controller
{
public function __construct(readonly SeoServiceInterface $seoPageProvider){}
public function __construct(
readonly GetAllAdditionalEducationsAction $getAllAdditionalEducationsAction,
readonly GetAdditionalEducationBySlugAction $getAdditionalEducationBySlugAction
){}
public function index(Request $request): \Inertia\Response
{
$cacheKey = md5(serialize($request->all()));
$data = $this->getAllAdditionalEducationsAction->run($request);
// Основные данные (кешируются)
$directionAdditionalEducations = Cache::remember(
CacheKeys::ADDITIONAL_EDUCATIONAL_PROGRAMS_PREFIX->value . 'directions_' . $cacheKey,
now()->addDay(),
function () {
return DirectionAdditionalEducationResource::collection(
DirectionAdditionalEducation::query()
->where('is_active', true)
->whereHas('additionalEducationCategories', fn ($q) => $q->whereHas('additionalEducations'))
->get()
);
}
);
$additionalEducations = Cache::remember(
CacheKeys::ADDITIONAL_EDUCATIONAL_PROGRAMS_PREFIX->value . $cacheKey,
now()->addDay(),
function () use ($request) {
$query = AdditionalEducationCategory::query()
->has('additionalEducations')
->withActivePrograms()
->where('is_active', true);
if ($request->has('form')) {
$formValue = FormEducation::fromName($request->form)->value;
$query->whereHas('additionalEducations', fn ($q) => $q->where('form_education', $formValue))
->with(['additionalEducations' => fn ($q) => $q->where('form_education', $formValue)]);
}
if ($request->has('category')) {
$slugs = is_array($request->category) ? $request->category : [$request->category];
$query->whereIn('slug', $slugs);
}
if ($request->has('direction')) {
$query->whereHas('direction', fn($q) => $q->where('slug', $request->direction));
}
return AdditionalEducationCategoryResource::collection($query->get());
}
);
$categories = Cache::remember(
CacheKeys::ADDITIONAL_EDUCATIONAL_PROGRAMS_PREFIX->value . 'categories',
now()->addWeek(),
function () {
return AdditionalEducationCategoryPreviewResource::collection(
AdditionalEducationCategory::query()
->where('is_active', true)
->has('additionalEducations')
->get()
);
}
);
// Динамические данные (не кешируются)
$categoriesContent = [];
if ($request->category) {
foreach ((array)$request->category as $item) {
$categoriesContent[$item] = new AdditionalEducationCategoryResource(
AdditionalEducationCategory::where('slug', $item)->first()
);
}
}
$forms_education = array_reduce(
FormEducation::cases(),
fn ($acc, $case) => $acc + [$case->name => $case->getLabel()],
[]
);
$filters = [
'direction_filter' => [
'type' => 'direction',
'value' => $request->input('direction'),
'param' => 'direction'
],
'form_education_filter' => [
'type' => 'form',
'value' => $request->input('form'),
'param' => 'form'
],
'category_filter' => [
'type' => 'category',
'value' => $request->input('category'),
'param' => 'category',
'content' => $categoriesContent,
],
];
$seo = $this->seoPageProvider->getSeoForCurrentPage();
return Inertia::render('Client/Additional-educations/Index', compact(
'directionAdditionalEducations',
'additionalEducations',
'filters',
'forms_education',
'categories',
'seo'
));
return Inertia::render('Client/Additional-educations/Index', $data);
}
public function show(string $slug): \Inertia\Response
public function show(string $slug, Request $request): \Inertia\Response
{
$additionalEducationModel = Cache::remember(
CacheKeys::ADDITIONAL_EDUCATIONAL_PROGRAM_PREFIX->value . $slug,
now()->addDay(),
fn() => AdditionalEducation::with('category.direction')->where('slug', $slug)->firstOrFail()
);
$data = $this->getAdditionalEducationBySlugAction->run($slug, $request);
$seo = Cache::remember(
CacheKeys::ADDITIONAL_EDUCATIONAL_PROGRAM_PREFIX->value . 'seo_' . $slug,
now()->addDay(),
fn() => $this->seoPageProvider->getSeoForModel($additionalEducationModel)
);
$settingsPage = request()->attributes->get('settings_page') ?? [];
if (array_key_exists('custom_form', $settingsPage)) {
$form = $settingsPage['custom_form'];
} else {
$form = null;
}
$additionalEducation = new AdditionalEducationResource($additionalEducationModel);
return Inertia::render('Client/Additional-educations/Show', compact(
'additionalEducation',
'seo',
'form',
));
}}
return Inertia::render('Client/Additional-educations/Show', $data);
}
}
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Containers\Analytics\Commands;
use App\Containers\Analytics\Models\AnalyticsDailyStat;
use App\Containers\Analytics\Models\AnalyticsHit;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\Facades\DB;
class AggregateAnalyticsCommand extends \Illuminate\Console\Command
{
protected $signature = 'analytics:aggregate {--days=1 : Days back to aggregate}';
protected $description = 'Aggregate analytics hits into daily stats';
public function handle(): int
{
$days = (int) $this->option('days');
$date = now()->subDays($days)->toDateString();
$stats = AnalyticsHit::selectRaw('
DATE(created_at) as hit_date,
url,
COUNT(*) as views_count,
COUNT(DISTINCT session_id) as unique_visitors
')
->whereDate('created_at', $date)
->groupBy(DB::raw('DATE(created_at)'), 'url')
->get();
$inserted = 0;
foreach ($stats as $row) {
AnalyticsDailyStat::updateOrCreate(
['date' => $row->hit_date, 'url' => $row->url],
[
'views_count' => $row->views_count,
'unique_visitors' => $row->unique_visitors,
]
);
$inserted++;
}
$this->info("Aggregated {$inserted} daily stat rows for {$date}");
return static::SUCCESS;
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\Containers\Analytics\Jobs;
use App\Containers\Analytics\Models\AnalyticsHit;
use App\Containers\Analytics\Models\AnalyticsSession;
use App\Containers\Analytics\Services\DeviceDetectorService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class RecordVisitJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 15;
public function __construct(
public array $data,
) {}
public function handle(
DeviceDetectorService $detector,
): void {
$userAgent = $this->data['user_agent'] ?? '';
if ($detector->isBot($userAgent)) {
return;
}
$device = $detector->parse($userAgent);
$utmParams = array_filter([
'utm_source' => $this->data['utm_source'] ?? null,
'utm_medium' => $this->data['utm_medium'] ?? null,
'utm_campaign' => $this->data['utm_campaign'] ?? null,
], fn($v) => $v !== null);
$session = (new AnalyticsSession())->firstOrCreateSession(
visitorId: $this->data['visitor_id'],
ip: $this->data['ip'] ?? '0.0.0.0',
userId: $this->data['user_id'] ?? null,
entryPage: $this->data['url'],
utmParams: $utmParams,
);
if (empty($session->browser)) {
$session->update([
'browser' => $device['browser'],
'os' => $device['os'],
'device_type' => $device['type'],
]);
}
AnalyticsHit::create([
'session_id' => $session->id,
'user_id' => $this->data['user_id'] ?? null,
'url' => $this->data['url'],
'referrer' => $this->data['referrer'] ?? null,
'title' => $this->data['title'] ?? null,
'created_at' => now(),
]);
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Containers\Analytics\Models;
use Illuminate\Database\Eloquent\Model;
class AnalyticsDailyStat extends Model
{
protected $table = 'analytics_daily_stats';
protected $fillable = [
'date',
'url',
'views_count',
'unique_visitors',
];
protected $casts = [
'date' => 'date',
'views_count' => 'integer',
'unique_visitors' => 'integer',
];
}
@@ -0,0 +1,36 @@
<?php
namespace App\Containers\Analytics\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AnalyticsHit extends Model
{
protected $table = 'analytics_hits';
public $timestamps = false;
protected $fillable = [
'session_id',
'user_id',
'url',
'referrer',
'title',
'created_at',
];
protected $casts = [
'created_at' => 'datetime',
];
public function session(): BelongsTo
{
return $this->belongsTo(AnalyticsSession::class, 'session_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(\App\Containers\User\Models\User::class, 'user_id');
}
}
@@ -0,0 +1,72 @@
<?php
namespace App\Containers\Analytics\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AnalyticsSession extends Model
{
protected $table = 'analytics_sessions';
protected $fillable = [
'visitor_id',
'user_id',
'entry_page',
'ip',
'utm_source',
'utm_medium',
'utm_campaign',
'browser',
'os',
'device_type',
'started_at',
'last_activity_at',
];
protected $casts = [
'started_at' => 'datetime',
'last_activity_at' => 'datetime',
];
public function hits(): HasMany
{
return $this->hasMany(AnalyticsHit::class, 'session_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(\App\Containers\User\Models\User::class, 'user_id');
}
public function firstOrCreateSession(
string $visitorId,
string $ip,
int $userId = null,
string $entryPage = null,
array $utmParams = [],
): self {
$lastSession = static::where('visitor_id', $visitorId)
->where('last_activity_at', '>', now()->subMinutes(30))
->latest('last_activity_at')
->first();
if ($lastSession) {
$lastSession->update(['last_activity_at' => now()]);
return $lastSession;
}
return static::create([
'visitor_id' => $visitorId,
'user_id' => $userId,
'entry_page' => $entryPage,
'ip' => $ip,
'utm_source' => $utmParams['utm_source'] ?? null,
'utm_medium' => $utmParams['utm_medium'] ?? null,
'utm_campaign' => $utmParams['utm_campaign'] ?? null,
'started_at' => now(),
'last_activity_at' => now(),
]);
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Containers\Analytics\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Console\Scheduling\Schedule;
class AnalyticsServiceProvider extends ServiceProvider
{
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->commands([
\App\Containers\Analytics\Commands\AggregateAnalyticsCommand::class,
]);
$this->app->afterResolving(Schedule::class, function (Schedule $schedule) {
$schedule->command('analytics:aggregate', ['--days' => 1])->hourly();
});
}
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Containers\Analytics\Services;
use WhichBrowser\Parser;
class DeviceDetectorService
{
public function parse(string $userAgent): array
{
$parser = new Parser($userAgent);
$browser = $parser->browser->name ?? null;
$os = $parser->os->name ?? null;
$deviceType = 'desktop';
if ($parser->isType('tablet')) {
$deviceType = 'tablet';
} elseif ($parser->isType('mobile')) {
$deviceType = 'mobile';
}
return [
'browser' => $browser,
'os' => $os,
'type' => $deviceType,
];
}
public function isBot(string $userAgent): bool
{
$parser = new Parser($userAgent);
return $parser->isType('bot');
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Containers\Analytics\Services;
use App\Containers\Analytics\Models\AnalyticsGeoCache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class GeoIpService
{
private const CACHE_TTL_DAYS = 7;
public function locate(string $ip): ?array
{
$ipHash = sha1($ip);
$cached = AnalyticsGeoCache::where('ip_hash', $ipHash)
->where('cached_at', '>', now()->subDays(self::CACHE_TTL_DAYS))
->first();
if ($cached) {
return [
'country' => $cached->country,
'city' => $cached->city,
];
}
try {
$response = Http::timeout(3)->get("http://ip-api.com/json/{$ip}", [
'fields' => 'country,city',
]);
if ($response->successful()) {
$data = $response->json();
AnalyticsGeoCache::updateOrCreate(
['ip_hash' => $ipHash],
[
'country' => $data['country'] ?? null,
'city' => $data['city'] ?? null,
'cached_at' => now(),
]
);
return [
'country' => $data['country'] ?? null,
'city' => $data['city'] ?? null,
];
}
} catch (\Throwable $e) {
Log::warning('GeoIP lookup failed', ['ip' => $ip, 'error' => $e->getMessage()]);
}
return null;
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsHit;
use App\Containers\Analytics\Models\AnalyticsSession;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class GetAnalyticsOverviewTask
{
public function run(int $days = 30): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$to = now()->endOfDay();
if ($days === 0) {
$prevFrom = Carbon::yesterday()->startOfDay();
$prevTo = Carbon::yesterday()->endOfDay();
} else {
$prevFrom = Carbon::now()->subDays($days * 2)->startOfDay();
$prevTo = Carbon::now()->subDays($days)->startOfDay();
}
$current = $this->getPeriodStats($from, $to);
$previous = $this->getPeriodStats($prevFrom, $prevTo);
return [
'unique_visitors' => $this->compare($current['visitors'], $previous['visitors']),
'page_views' => $this->compare($current['views'], $previous['views']),
'avg_time' => $this->compare($current['avg_time'], $previous['avg_time'], true),
'bounce_rate' => $this->compare($current['bounce_rate'], $previous['bounce_rate'], true, true),
];
}
private function getPeriodStats(Carbon $from, Carbon $to): array
{
$hits = AnalyticsHit::whereBetween('created_at', [$from, $to])->count();
$sessions = AnalyticsSession::whereBetween('started_at', [$from, $to])
->get(['id', 'started_at', 'last_activity_at']);
$uniqueVisitors = AnalyticsSession::whereBetween('started_at', [$from, $to])
->distinct('visitor_id')
->count('visitor_id');
$totalSessions = $sessions->count();
$avgTime = 0;
if ($totalSessions > 0) {
$totalSeconds = $sessions->sum(function ($s) {
return $s->started_at->diffInSeconds($s->last_activity_at);
});
$avgTime = (int) round($totalSeconds / $totalSessions);
}
$bounceRate = 0;
if ($totalSessions > 0) {
$sessionIds = $sessions->pluck('id');
$singleHitSessions = AnalyticsHit::whereIn('session_id', $sessionIds)
->selectRaw('session_id, COUNT(*) as hits')
->groupBy('session_id')
->havingRaw('COUNT(*) = 1')
->count();
$bounceRate = (int) round(($singleHitSessions / $totalSessions) * 100);
}
return [
'views' => $hits,
'visitors' => $uniqueVisitors,
'avg_time' => $avgTime,
'bounce_rate' => $bounceRate,
];
}
private function compare(int $current, int $previous, bool $isRatio = false, bool $invertDirection = false): array
{
$change = 0;
if ($previous > 0) {
$change = $isRatio
? $current - $previous
: (int) round((($current - $previous) / $previous) * 100);
} elseif ($current > 0) {
$change = $isRatio ? 0 : 100;
}
$direction = $invertDirection
? ($change < 0 ? 'up' : ($change > 0 ? 'down' : 'neutral'))
: ($change > 0 ? 'up' : ($change < 0 ? 'down' : 'neutral'));
return [
'value' => $current,
'change' => $change,
'direction' => $direction,
];
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsDailyStat;
use App\Containers\Analytics\Models\AnalyticsHit;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class GetDailyStatsTask
{
public function run(int $days = 30): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$stats = AnalyticsDailyStat::where('date', '>=', $from)
->selectRaw('
date,
SUM(views_count) as total_views,
SUM(unique_visitors) as total_unique
')
->groupBy('date')
->orderBy('date')
->get();
if ($stats->isEmpty()) {
return AnalyticsHit::where('created_at', '>=', $from)
->selectRaw('
DATE(created_at) as hit_date,
COUNT(*) as total_views,
COUNT(DISTINCT session_id) as total_unique
')
->groupBy(DB::raw('DATE(created_at)'))
->orderBy('hit_date')
->get()
->map(fn($row) => [
'date' => (string) $row->hit_date,
'views' => (int) $row->total_views,
'visitors' => (int) $row->total_unique,
])
->toArray();
}
return $stats->map(fn($row) => [
'date' => $row->date instanceof Carbon ? $row->date->format('Y-m-d') : (string) $row->date,
'views' => (int) $row->total_views,
'visitors' => (int) $row->total_unique,
])->toArray();
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsSession;
use Illuminate\Support\Carbon;
class GetDevicesTask
{
public function run(int $days = 30): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$devices = AnalyticsSession::where('started_at', '>=', $from)
->selectRaw('
device_type,
COUNT(*) as count
')
->groupBy('device_type')
->get()
->pluck('count', 'device_type')
->toArray();
$browsers = AnalyticsSession::where('started_at', '>=', $from)
->whereNotNull('browser')
->selectRaw('
browser,
COUNT(*) as count
')
->groupBy('browser')
->orderByDesc('count')
->limit(10)
->get()
->pluck('count', 'browser')
->toArray();
$oses = AnalyticsSession::where('started_at', '>=', $from)
->whereNotNull('os')
->selectRaw('
os,
COUNT(*) as count
')
->groupBy('os')
->orderByDesc('count')
->limit(10)
->get()
->pluck('count', 'os')
->toArray();
return [
'devices' => $devices,
'browsers' => $browsers,
'oses' => $oses,
];
}
}
@@ -0,0 +1,134 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsHit;
use App\Containers\AppStructure\Models\MainSection;
use Illuminate\Support\Carbon;
class GetNavigationSectionsTask
{
public function run(int $days = 30): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$hits = AnalyticsHit::where('created_at', '>=', $from)
->selectRaw('url, session_id')
->get();
$mainSections = MainSection::with('subSections.pages')
->orderBy('sort')
->get();
$sections = [];
$cmsSlugs = [];
// 1. CMS sections (MainSection → SubSection → Page)
foreach ($mainSections as $ms) {
$msPrefix = '/' . $ms->slug;
$cmsSlugs[] = $msPrefix;
$msHits = $hits->filter(fn($h) => str_starts_with(rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/'), $msPrefix));
$subSections = [];
foreach ($ms->subSections as $ss) {
$ssPrefix = $msPrefix . '/' . $ss->slug;
$ssHits = $msHits->filter(fn($h) => str_starts_with(rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/'), $ssPrefix));
$pages = [];
foreach ($ss->pages as $page) {
$pagePath = '/' . $page->path;
$pHits = $ssHits->filter(fn($h) => rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/') === $pagePath);
$pages[] = [
'id' => $page->id,
'title' => $page->title ?: $page->slug,
'path' => $page->path,
'url' => $pagePath,
'views' => $pHits->count(),
'visitors' => $pHits->pluck('session_id')->unique()->count(),
];
}
$subSections[] = [
'id' => $ss->id,
'title' => $ss->title,
'slug' => $ss->slug,
'views' => $ssHits->count(),
'visitors' => $ssHits->pluck('session_id')->unique()->count(),
'pages' => $pages,
];
}
$sections[] = [
'id' => $ms->id,
'title' => $ms->title,
'slug' => $ms->slug,
'group' => 'structure',
'views' => $msHits->count(),
'visitors' => $msHits->pluck('session_id')->unique()->count(),
'sub_sections' => $subSections,
];
}
// 2. Config-defined sections (non-CMS)
$configSections = config('analytics.sections', []);
foreach ($configSections as $prefix => $config) {
if ($prefix === '/') continue;
if (in_array($prefix, $cmsSlugs)) continue;
$sectionHits = $hits->filter(fn($h) => str_starts_with(rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/'), $prefix));
$subSections = [];
if (isset($config['children'])) {
$model = $config['children']['model'];
$nameKey = $config['children']['name_key'];
$slugKey = $config['children']['slug_key'];
$children = $model::all();
foreach ($children as $child) {
$childSlug = $child->{$slugKey};
$childPrefix = $prefix . '/' . $childSlug;
$childHits = $sectionHits->filter(fn($h) => str_starts_with(rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/'), $childPrefix));
$subSections[] = [
'id' => $child->id,
'title' => $child->{$nameKey},
'slug' => $childSlug,
'views' => $childHits->count(),
'visitors' => $childHits->pluck('session_id')->unique()->count(),
'pages' => [],
];
}
}
$sections[] = [
'id' => 'config:' . $prefix,
'title' => $config['label'],
'slug' => ltrim($prefix, '/'),
'group' => $config['group'] ?? 'other',
'views' => $sectionHits->count(),
'visitors' => $sectionHits->pluck('session_id')->unique()->count(),
'sub_sections' => $subSections,
];
}
// Homepage — as first item in structure group
$homeHits = $hits->filter(fn($h) => (rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/') === '/'));
array_unshift($sections, [
'id' => 'home',
'title' => 'Главная страница',
'slug' => '',
'group' => 'structure',
'type' => 'home',
'views' => $homeHits->count(),
'visitors' => $homeHits->pluck('session_id')->unique()->count(),
'sub_sections' => [],
]);
return [
'sections' => $sections,
'groupLabels' => config('analytics.group_labels', []),
];
}
}
@@ -0,0 +1,62 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsHit;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class GetReferrersTask
{
public function run(int $days = 30, int $limit = 10): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$referrers = AnalyticsHit::where('created_at', '>=', $from)
->whereNotNull('referrer')
->where('referrer', '!=', '')
->selectRaw('
CASE
WHEN referrer LIKE "%google.%" THEN "Google"
WHEN referrer LIKE "%yandex.%" THEN "Yandex"
WHEN referrer LIKE "%vk.com%" THEN "VK"
WHEN referrer LIKE "%t.me%" THEN "Telegram"
WHEN referrer LIKE "%ok.ru%" THEN "Одноклассники"
WHEN referrer LIKE "%dzen.ru%" THEN "Дзен"
WHEN referrer LIKE "%facebook.%" THEN "Facebook"
WHEN referrer LIKE "%instagram.%" THEN "Instagram"
WHEN referrer LIKE "%twitter.%" THEN "Twitter"
ELSE SUBSTRING_INDEX(SUBSTRING_INDEX(referrer, "/", 3), "/", -1)
END as source,
COUNT(*) as hits,
COUNT(DISTINCT session_id) as visitors
')
->groupBy('source')
->orderByDesc('hits')
->limit($limit)
->get()
->toArray();
$directCount = AnalyticsHit::where('created_at', '>=', $from)
->where(function ($q) {
$q->whereNull('referrer')->orWhere('referrer', '');
})
->count();
$results = array_map(fn($r) => [
'source' => $r['source'],
'hits' => (int) $r['hits'],
'visitors' => (int) $r['visitors'],
], $referrers);
if ($directCount > 0) {
array_unshift($results, [
'source' => 'Прямой заход',
'hits' => $directCount,
'visitors' => $directCount,
]);
}
return $results;
}
}
@@ -0,0 +1,216 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsHit;
use App\Containers\Analytics\Models\AnalyticsSession;
use App\Containers\AppStructure\Models\MainSection;
use App\Containers\AppStructure\Models\Page;
use App\Containers\AppStructure\Models\SubSection;
use Illuminate\Support\Carbon;
class GetSectionDetailTask
{
public function run(string $prefix, int $days = 30): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$hits = AnalyticsHit::where('created_at', '>=', $from)
->where('url', 'like', $prefix . '%')
->selectRaw('url, session_id, created_at, referrer')
->get();
$totalViews = $hits->count();
$uniqueVisitors = $hits->pluck('session_id')->unique()->count();
$mainSection = null;
$subSection = null;
$page = null;
$children = [];
$breadcrumbs = [];
$type = 'unknown';
$exitPages = [];
$cleanPrefix = ltrim($prefix, '/');
$parts = array_filter(explode('/', $cleanPrefix));
// 1. Check if MainSection
$mainSection = MainSection::where('slug', $cleanPrefix)->first();
if ($mainSection) {
$type = 'main_section';
$breadcrumbs = [['title' => 'Аналитика', 'url' => route('dashboard.analytics.index')]];
$subSections = SubSection::where('main_section_id', $mainSection->id)
->with('pages')
->orderBy('sort')
->get();
foreach ($subSections as $ss) {
$ssPrefix = '/' . $mainSection->slug . '/' . $ss->slug;
$ssHits = $hits->filter(fn($h) => str_starts_with(parse_url($h->url, PHP_URL_PATH) ?: '', $ssPrefix));
$children[] = [
'id' => $ss->id,
'title' => $ss->title,
'prefix' => $ssPrefix,
'views' => $ssHits->count(),
'visitors' => $ssHits->pluck('session_id')->unique()->count(),
'pages_count' => $ss->pages->count(),
];
}
}
// 2. Check if SubSection (2 parts)
elseif (count($parts) === 2) {
$mainSection = MainSection::where('slug', $parts[0])->first();
$subSection = SubSection::where('slug', $parts[1])
->where('main_section_id', $mainSection?->id)
->first();
if ($subSection && $mainSection) {
$type = 'sub_section';
$breadcrumbs = [
['title' => 'Аналитика', 'url' => route('dashboard.analytics.index')],
['title' => $mainSection->title, 'url' => route('dashboard.analytics.section', ['prefix' => '/' . $mainSection->slug])],
];
$pages = Page::where('sub_section_id', $subSection->id)
->orderBy('sort')
->get();
foreach ($pages as $p) {
$pUrl = '/' . $p->path;
$pHits = $hits->filter(fn($h) => rtrim(parse_url($h->url, PHP_URL_PATH) ?: '', '/') === $pUrl);
$children[] = [
'id' => $p->id,
'title' => $p->title ?: $p->slug,
'url' => $pUrl,
'views' => $pHits->count(),
'visitors' => $pHits->pluck('session_id')->unique()->count(),
];
}
}
}
// 3. Check if Page (by path or 3+ parts)
if ($type === 'unknown') {
$page = Page::where('path', $cleanPrefix)->first();
if ($page && $page->section) {
$subSection = $page->section;
$mainSection = $subSection->mainSection;
}
if ($mainSection && $subSection) {
$type = 'page';
$breadcrumbs = [
['title' => 'Аналитика', 'url' => route('dashboard.analytics.index')],
['title' => $mainSection->title, 'url' => route('dashboard.analytics.section', ['prefix' => '/' . $mainSection->slug])],
['title' => $subSection->title, 'url' => route('dashboard.analytics.section', ['prefix' => '/' . $mainSection->slug . '/' . $subSection->slug])],
];
// Exit pages: URLs visited after this page in the same session
$exitPages = $this->getExitPages($hits, $prefix, $days);
}
}
$label = $cleanPrefix;
if ($mainSection) $label = $mainSection->title;
if ($subSection) $label = $subSection->title;
if ($page) $label = $page->title ?: $page->slug;
$topPages = $hits->groupBy('url')
->map(fn($group) => [
'url' => $group->first()->url,
'views' => $group->count(),
'unique_visitors' => $group->pluck('session_id')->unique()->count(),
])
->sortByDesc('views')
->values()
->take(15)
->toArray();
$dailyStats = $hits->groupBy(fn($h) => $h->created_at->format('Y-m-d'))
->map(fn($group, $date) => [
'date' => $date,
'views' => $group->count(),
'visitors' => $group->pluck('session_id')->unique()->count(),
])
->values()
->sortBy('date')
->toArray();
$referrers = $hits->filter(fn($h) => !empty($h->referrer))
->groupBy(function ($h) {
$url = $h->referrer;
if (str_contains($url, 'google.')) return 'Google';
if (str_contains($url, 'yandex.')) return 'Yandex';
if (str_contains($url, 'vk.com')) return 'VK';
if (str_contains($url, 't.me')) return 'Telegram';
if (str_contains($url, 'ok.ru')) return 'Одноклассники';
if (str_contains($url, 'dzen.ru')) return 'Дзен';
return parse_url($url, PHP_URL_HOST) ?? 'Другое';
})
->map(fn($group) => [
'source' => $group->first()->referrer,
'hits' => $group->count(),
'visitors' => $group->pluck('session_id')->unique()->count(),
])
->sortByDesc('hits')
->values()
->take(10)
->toArray();
$devices = AnalyticsSession::where('started_at', '>=', $from)
->whereIn('id', $hits->pluck('session_id')->unique())
->selectRaw('device_type, COUNT(*) as count')
->groupBy('device_type')
->pluck('count', 'device_type')
->toArray();
return [
'prefix' => $prefix,
'type' => $type,
'label' => $label,
'breadcrumbs' => $breadcrumbs,
'children' => $children,
'exit_pages' => $exitPages,
'total_views' => $totalViews,
'unique_visitors' => $uniqueVisitors,
'top_pages' => $topPages,
'daily_stats' => $dailyStats,
'referrers' => $referrers,
'devices' => $devices,
];
}
private function getExitPages($hits, string $prefix, int $days): array
{
$sessionIds = $hits->pluck('session_id')->unique();
$allHits = AnalyticsHit::where('created_at', '>=', now()->subDays($days)->startOfDay())
->whereIn('session_id', $sessionIds)
->orderBy('session_id')
->orderBy('created_at')
->selectRaw('session_id, url, created_at')
->get()
->groupBy('session_id');
$exitCounts = [];
foreach ($allHits as $sessionId => $sessionHits) {
$sorted = $sessionHits->values();
for ($i = 0; $i < $sorted->count() - 1; $i++) {
$currentUrl = parse_url($sorted[$i]->url, PHP_URL_PATH) ?: '/';
if (str_starts_with($currentUrl, $prefix)) {
$nextUrl = parse_url($sorted[$i + 1]->url, PHP_URL_PATH) ?: '/';
if (!isset($exitCounts[$nextUrl])) {
$exitCounts[$nextUrl] = 0;
}
$exitCounts[$nextUrl]++;
}
}
}
arsort($exitCounts);
return array_slice(array_map(fn($url, $count) => [
'url' => $url,
'count' => $count,
], array_keys($exitCounts), $exitCounts), 0, 10);
}
}
@@ -0,0 +1,71 @@
<?php
namespace App\Containers\Analytics\Tasks;
use App\Containers\Analytics\Models\AnalyticsHit;
use App\Containers\AppStructure\Models\MainSection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class GetSectionsStatsTask
{
public function run(int $days = 30): array
{
$from = Carbon::now()->subDays($days)->startOfDay();
$sections = config('analytics.sections', []);
$hits = AnalyticsHit::where('created_at', '>=', $from)
->selectRaw('url, session_id')
->get();
$grouped = [];
foreach ($hits as $hit) {
$prefix = $this->resolvePrefix($hit->url, $sections);
if (!isset($grouped[$prefix])) {
$grouped[$prefix] = ['views' => 0, 'visitors' => []];
}
$grouped[$prefix]['views']++;
$grouped[$prefix]['visitors'][$hit->session_id] = true;
}
$result = [];
foreach ($grouped as $prefix => $data) {
$config = $sections[$prefix] ?? null;
$result[] = [
'prefix' => $prefix,
'label' => $config['label'] ?? $prefix,
'icon' => $config['icon'] ?? 'folder',
'views' => $data['views'],
'visitors' => count($data['visitors']),
'order' => $config['order'] ?? 99,
];
}
usort($result, fn($a, $b) => $a['order'] <=> $b['order']);
return $result;
}
private function resolvePrefix(string $url, array $sections): string
{
$path = parse_url($url, PHP_URL_PATH) ?: '/';
$segments = explode('/', trim($path, '/'));
$firstSegment = '/' . ($segments[0] ?? '');
if (isset($sections[$firstSegment])) {
return $firstSegment;
}
if ($firstSegment === '/' && $path === '/') {
return '/';
}
// Check main sections for CMS pages
$mainSection = MainSection::where('slug', $segments[0] ?? '')->first();
if ($mainSection) {
return '/page';
}
return '/other';
}
}

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