From 12792db1854adb1964e51e1a7acb9fa467d13cca Mon Sep 17 00:00:00 2001 From: Darkraise Date: Wed, 4 Feb 2026 06:03:39 +0700 Subject: [PATCH] chore: apply consistent code formatting across codebase --- CLAUDE.md | 23 +- CONTRIBUTING.md | 60 ++- backend/.eslintrc.json | 16 +- backend/README.md | 6 +- backend/scripts/fix-absolute-paths.ts | 5 +- backend/scripts/kill-port.js | 1 - backend/src/config.ts | 15 +- backend/src/jobs/MetadataSyncJob.ts | 9 +- backend/src/jobs/RuffleUpdateJob.ts | 4 +- backend/src/middleware/MAINTENANCE_MODE.md | 89 +++- backend/src/middleware/activityLogger.ts | 2 +- backend/src/middleware/asyncHandler.ts | 6 +- backend/src/middleware/auth.ts | 18 +- backend/src/middleware/dateValidation.ts | 4 +- backend/src/middleware/errorHandler.ts | 16 +- backend/src/middleware/featureFlags.ts | 5 +- backend/src/middleware/maintenanceMode.ts | 49 +- backend/src/middleware/performanceTracking.ts | 16 +- backend/src/middleware/rbac.ts | 4 +- backend/src/middleware/requestTimeout.ts | 18 +- backend/src/middleware/validation.ts | 8 +- backend/src/migrations/README.md | 36 +- backend/src/migrations/archived/README.md | 46 +- backend/src/proxy/config.ts | 2 +- backend/src/proxy/gamezipserver.ts | 11 +- backend/src/proxy/legacy-server.ts | 23 +- backend/src/proxy/mimeTypes.ts | 4 +- backend/src/proxy/proxy-request-handler.ts | 8 +- backend/src/proxy/server.ts | 62 ++- backend/src/proxy/zip-manager.ts | 10 +- backend/src/routes/activities.ts | 42 +- backend/src/routes/auth-settings.ts | 13 +- backend/src/routes/auth.ts | 237 +++++---- backend/src/routes/cache.ts | 8 +- backend/src/routes/community-playlists.ts | 4 +- backend/src/routes/database.ts | 68 +-- backend/src/routes/domains.ts | 8 +- backend/src/routes/downloads.ts | 495 +++++++++--------- backend/src/routes/errors.ts | 53 +- backend/src/routes/favorites.ts | 14 +- backend/src/routes/game-files.ts | 67 +-- backend/src/routes/games.ts | 96 ++-- backend/src/routes/github.ts | 16 +- backend/src/routes/health.ts | 32 +- backend/src/routes/index.ts | 4 +- backend/src/routes/jobs.ts | 10 +- backend/src/routes/metrics.ts | 12 +- backend/src/routes/platforms.ts | 13 +- backend/src/routes/play-tracking.ts | 144 ++--- backend/src/routes/playlists.ts | 34 +- backend/src/routes/proxy.ts | 186 ++++--- backend/src/routes/roles.ts | 13 +- backend/src/routes/ruffle.ts | 28 +- backend/src/routes/shared-playlists.ts | 16 +- backend/src/routes/statistics.ts | 11 +- backend/src/routes/system-settings.ts | 52 +- backend/src/routes/tags.ts | 40 +- backend/src/routes/updates.ts | 45 +- backend/src/routes/user-playlists.ts | 34 +- backend/src/routes/users.ts | 24 +- backend/src/server.ts | 97 ++-- backend/src/services/ActivityService.ts | 102 ++-- backend/src/services/AuthService.ts | 97 ++-- backend/src/services/AuthSettingsService.ts | 32 +- .../services/CachedSystemSettingsService.ts | 6 +- .../src/services/CommunityPlaylistService.ts | 99 ++-- backend/src/services/DatabaseService.ts | 106 ++-- backend/src/services/DomainService.ts | 20 +- backend/src/services/DownloadManager.ts | 55 +- backend/src/services/FallbackCache.ts | 10 +- backend/src/services/FavoritesService.ts | 2 +- backend/src/services/FileImporter.ts | 14 +- backend/src/services/GameDataService.ts | 44 +- backend/src/services/GameDatabaseUpdater.ts | 28 +- backend/src/services/GameFileService.ts | 29 +- backend/src/services/GameSearchCache.ts | 56 +- backend/src/services/GameService.ts | 42 +- backend/src/services/HashValidator.ts | 14 +- backend/src/services/ImageUrlService.ts | 14 +- backend/src/services/JobExecutionService.ts | 20 +- backend/src/services/JobScheduler.ts | 8 +- backend/src/services/MetadataSyncService.ts | 114 ++-- backend/src/services/MetadataUpdateService.ts | 36 +- backend/src/services/PerformanceMetrics.ts | 22 +- backend/src/services/PermissionCache.ts | 27 +- backend/src/services/PlayTrackingService.ts | 56 +- backend/src/services/PlaylistService.ts | 35 +- backend/src/services/PreferencesService.ts | 8 +- backend/src/services/RoleService.ts | 35 +- backend/src/services/RuffleService.ts | 20 +- backend/src/services/StatisticsService.ts | 11 +- backend/src/services/SyncStatusService.ts | 6 +- backend/src/services/SystemSettingsService.ts | 21 +- backend/src/services/UpdateService.ts | 56 +- backend/src/services/UserDatabaseService.ts | 87 +-- backend/src/services/UserPlaylistService.ts | 58 +- backend/src/services/UserService.ts | 67 ++- backend/src/telemetry/index.ts | 7 +- backend/src/telemetry/otel.ts | 34 +- backend/src/utils/activityUtils.ts | 15 +- backend/src/utils/benchmarkGameSearch.ts | 14 +- backend/src/utils/jwt.ts | 2 +- backend/src/utils/logger.ts | 30 +- backend/src/utils/pagination.ts | 11 +- backend/src/utils/queryParser.ts | 10 +- backend/src/utils/queryPerformance.ts | 6 +- docs/01-overview/project-overview.md | 73 ++- docs/01-overview/technology-stack.md | 139 +++-- docs/02-architecture/README.md | 46 +- docs/02-architecture/authentication-flow.md | 61 ++- docs/02-architecture/data-flow-diagrams.md | 39 +- docs/02-architecture/game-launch-flow.md | 37 +- docs/02-architecture/play-tracking-flow.md | 36 +- docs/02-architecture/service-communication.md | 84 +-- docs/02-architecture/system-architecture.md | 62 ++- docs/03-backend/README.md | 34 +- docs/03-backend/architecture.md | 39 +- docs/03-backend/configuration.md | 78 ++- docs/03-backend/database/schema.md | 30 +- docs/03-backend/services/database-service.md | 33 +- .../services/permission-cache-service.md | 60 ++- .../services/user-database-service.md | 74 ++- docs/03-backend/utilities.md | 90 ++-- docs/04-frontend/README.md | 53 +- docs/04-frontend/api-client.md | 24 +- docs/04-frontend/architecture.md | 105 ++-- .../04-frontend/components/auth-components.md | 14 +- .../components/component-overview.md | 46 +- .../04-frontend/components/game-components.md | 14 +- .../components/layout-components.md | 36 +- .../components/player-components.md | 50 +- docs/04-frontend/components/ui-components.md | 13 +- docs/04-frontend/custom-hooks.md | 48 +- .../player-implementation/html5-player.md | 24 +- .../player-implementation/ruffle-player.md | 61 ++- .../state-management/react-query.md | 28 +- .../04-frontend/state-management/url-state.md | 10 +- .../state-management/zustand-stores.md | 149 ++++-- docs/04-frontend/views-routing.md | 21 +- docs/05-game-service/README.md | 35 +- docs/05-game-service/architecture.md | 115 +++- docs/05-game-service/configuration.md | 101 ++-- docs/05-game-service/gamezip-server.md | 35 +- docs/05-game-service/html-polyfills.md | 262 +++++---- docs/05-game-service/legacy-server.md | 48 +- docs/05-game-service/mime-types.md | 29 +- docs/05-game-service/proxy-server.md | 61 ++- docs/05-game-service/zip-manager.md | 91 +++- docs/06-api-reference/README.md | 25 +- docs/06-api-reference/activities-api.md | 41 +- docs/06-api-reference/admin-api.md | 40 +- docs/06-api-reference/authentication-api.md | 20 +- docs/06-api-reference/domains-api.md | 54 +- docs/06-api-reference/favorites-api.md | 37 +- docs/06-api-reference/games-api.md | 62 ++- docs/06-api-reference/platforms-tags-api.md | 26 +- docs/06-api-reference/play-tracking-api.md | 46 +- docs/06-api-reference/playlists-api.md | 19 +- docs/06-api-reference/roles-api.md | 35 +- docs/06-api-reference/settings-api.md | 63 ++- docs/06-api-reference/shared-playlists-api.md | 30 +- docs/06-api-reference/users-api.md | 13 +- docs/07-design-system/README.md | 15 +- docs/07-design-system/color-palette.md | 74 +-- docs/07-design-system/components-library.md | 146 ++++-- docs/07-design-system/design-patterns.md | 127 +++-- docs/07-design-system/icons.md | 41 +- docs/07-design-system/responsive-design.md | 45 +- docs/07-design-system/spacing-layout.md | 35 +- docs/07-design-system/theme-system.md | 56 +- docs/07-design-system/typography.md | 63 ++- docs/08-development/README.md | 15 +- docs/08-development/coding-standards.md | 58 +- docs/08-development/commands.md | 58 +- docs/08-development/common-pitfalls.md | 72 ++- docs/08-development/database-migrations.md | 65 ++- docs/08-development/debugging.md | 25 +- docs/08-development/project-structure.md | 11 + docs/08-development/setup-guide.md | 56 +- docs/08-development/testing-guide.md | 55 +- docs/09-deployment/README.md | 28 +- docs/09-deployment/docker-deployment.md | 58 +- docs/09-deployment/environment-variables.md | 102 ++-- docs/09-deployment/health-checks.md | 35 +- docs/09-deployment/security-considerations.md | 40 +- .../01-authentication-authorization.md | 71 ++- .../10-features/02-game-browsing-filtering.md | 54 +- docs/10-features/03-game-playing.md | 58 +- docs/10-features/04-play-session-tracking.md | 42 +- docs/10-features/05-playlists-favorites.md | 53 +- docs/10-features/06-user-management.md | 45 +- docs/10-features/07-role-permissions.md | 84 ++- docs/10-features/08-activity-logging.md | 64 ++- docs/10-features/09-system-settings.md | 82 ++- docs/10-features/10-date-time-formatting.md | 85 ++- docs/10-features/README.md | 97 ++-- docs/11-diagrams/README.md | 48 +- docs/12-reference/README.md | 123 +++-- docs/12-reference/cors-security-decision.md | 61 ++- docs/12-reference/database-indexes.md | 46 +- .../12-reference/database-schema-reference.md | 477 +++++++++-------- docs/12-reference/external-dependencies.md | 168 +++--- docs/12-reference/glossary.md | 202 ++++--- docs/12-reference/security-measures.md | 78 ++- docs/12-reference/type-definitions.md | 26 +- .../directory-traversal-protection.md | 43 +- docs/README.md | 109 ++-- frontend/.eslintrc.json | 17 +- frontend/postcss.config.js | 2 +- frontend/scripts/check-bundle-size.js | 212 -------- frontend/src/App.tsx | 486 ++++++++++------- .../components/activities/ActivityTable.tsx | 123 +++-- .../src/components/activities/FilterInput.tsx | 8 +- .../dashboard/ActivityDashboard.tsx | 22 +- .../activities/dashboard/ActivityStats.tsx | 125 ++--- .../dashboard/ActivityTrendChart.tsx | 38 +- .../dashboard/ResourceDistributionChart.tsx | 5 +- .../activities/dashboard/TopActionsChart.tsx | 21 +- .../activities/dashboard/UserLeaderboard.tsx | 24 +- frontend/src/components/auth/BrandPanel.tsx | 18 +- .../components/auth/CelestialBackground.tsx | 30 +- frontend/src/components/auth/LoginForm.tsx | 336 ++++++------ .../auth/ParticleNetworkBackground.tsx | 63 ++- .../components/auth/PrismaticBackground.tsx | 6 +- .../src/components/auth/ProtectedRoute.tsx | 17 +- frontend/src/components/auth/RegisterForm.tsx | 381 +++++++------- .../src/components/common/Breadcrumbs.tsx | 16 +- .../src/components/common/CardSizeControl.tsx | 41 +- .../src/components/common/ConfirmDialog.tsx | 5 +- .../src/components/common/ErrorBoundary.tsx | 20 +- .../src/components/common/FavoriteButton.tsx | 60 +-- frontend/src/components/common/LazyImage.tsx | 6 +- .../components/common/MobileWarningDialog.tsx | 4 +- .../common/NetworkStatusIndicator.tsx | 8 +- .../common/RemoveFavoriteButton.tsx | 28 +- frontend/src/components/common/RoleGuard.tsx | 2 +- .../components/dialogs/GamePlayerDialog.tsx | 5 +- frontend/src/components/error/ErrorPage.tsx | 19 +- .../src/components/error/ErrorReporter.ts | 18 +- .../error/GameLibraryErrorBoundary.tsx | 19 +- .../error/GamePlayerErrorBoundary.tsx | 40 +- .../error/PlaylistErrorBoundary.tsx | 27 +- frontend/src/components/jobs/JobCard.tsx | 117 +---- .../src/components/jobs/JobEditDialog.tsx | 74 ++- .../components/jobs/JobExecutionLogTable.tsx | 26 +- .../components/jobs/JobScheduleDisplay.tsx | 4 +- frontend/src/components/layout/AppShell.tsx | 8 +- .../src/components/layout/ErrorLayout.tsx | 6 +- frontend/src/components/layout/Header.tsx | 82 ++- frontend/src/components/layout/MainLayout.tsx | 4 +- frontend/src/components/layout/Sidebar.tsx | 48 +- .../src/components/library/GameCard.test.tsx | 42 +- frontend/src/components/library/GameCard.tsx | 2 +- frontend/src/components/library/GameList.tsx | 18 +- .../src/components/library/GameListItem.tsx | 44 +- .../components/library/GameListSkeleton.tsx | 2 +- frontend/src/components/player/GamePlayer.tsx | 25 +- frontend/src/components/player/README.md | 43 +- .../src/components/player/RufflePlayer.tsx | 136 ++--- .../playlist/AddToPlaylistModal.tsx | 16 +- .../BrowseCommunityPlaylistsModal.tsx | 208 ++++---- .../playlist/CreatePlaylistModal.tsx | 168 +++--- .../playlist/CreateUserPlaylistDialog.tsx | 12 +- .../src/components/playlist/IconSelector.tsx | 13 +- .../components/roles/PermissionSelector.tsx | 51 +- frontend/src/components/roles/RoleForm.tsx | 41 +- frontend/src/components/roles/RoleTable.tsx | 34 +- .../src/components/search/FilterChips.tsx | 2 +- .../src/components/search/FilterDropdown.tsx | 28 +- .../src/components/search/FilterPanel.tsx | 26 +- .../src/components/search/filterConfig.ts | 17 +- .../components/settings/AppSettingsTab.tsx | 115 ++-- .../settings/FeaturesSettingsTab.tsx | 59 +-- .../settings/GeneralSettingsTab.tsx | 70 ++- .../components/settings/UpdateSettingsTab.tsx | 440 +++++++--------- .../stats/GamesDistributionChart.tsx | 65 ++- .../src/components/stats/PlaytimeChart.tsx | 107 ++-- .../src/components/stats/TopGamesChart.tsx | 71 +-- .../src/components/stats/UserStatsPanel.tsx | 32 +- frontend/src/components/theme/ThemePicker.tsx | 7 +- frontend/src/components/ui/alert-dialog.tsx | 94 ++-- frontend/src/components/ui/alert.tsx | 60 +-- frontend/src/components/ui/avatar.tsx | 31 +- frontend/src/components/ui/badge.tsx | 46 +- frontend/src/components/ui/button.tsx | 59 +-- frontend/src/components/ui/calendar.tsx | 144 ++--- frontend/src/components/ui/card.tsx | 118 ++--- frontend/src/components/ui/checkbox.tsx | 20 +- frontend/src/components/ui/collapsible.tsx | 10 +- frontend/src/components/ui/data-table.tsx | 98 ++-- frontend/src/components/ui/date-picker.tsx | 28 +- frontend/src/components/ui/dialog.tsx | 65 +-- frontend/src/components/ui/dropdown-menu.tsx | 103 ++-- frontend/src/components/ui/form.tsx | 132 +++-- frontend/src/components/ui/input.tsx | 16 +- frontend/src/components/ui/label.tsx | 27 +- frontend/src/components/ui/pagination.tsx | 17 +- frontend/src/components/ui/popover.tsx | 20 +- frontend/src/components/ui/radio-group.tsx | 30 +- frontend/src/components/ui/scroll-area.tsx | 30 +- frontend/src/components/ui/select.tsx | 79 ++- frontend/src/components/ui/separator.tsx | 41 +- frontend/src/components/ui/sheet.tsx | 100 ++-- frontend/src/components/ui/sonner.tsx | 50 +- frontend/src/components/ui/switch.tsx | 16 +- frontend/src/components/ui/table.tsx | 110 ++-- frontend/src/components/ui/tabs.tsx | 28 +- frontend/src/components/ui/textarea.tsx | 37 +- frontend/src/components/ui/toast.tsx | 73 ++- frontend/src/components/ui/toaster.tsx | 16 +- frontend/src/components/ui/toggle-group.tsx | 40 +- frontend/src/components/ui/toggle.tsx | 36 +- frontend/src/components/ui/tooltip.tsx | 20 +- .../components/users/ChangePasswordDialog.tsx | 62 +-- frontend/src/components/users/UserForm.tsx | 79 +-- frontend/src/components/users/UserTable.tsx | 22 +- frontend/src/contexts/AuthContext.tsx | 91 ++-- .../src/hooks/factories/createQueryHook.ts | 22 +- frontend/src/hooks/use-toast.ts | 153 +++--- frontend/src/hooks/useActivities.ts | 26 +- frontend/src/hooks/useActivityFilters.ts | 20 +- frontend/src/hooks/useAuth.ts | 8 +- frontend/src/hooks/useCommunityPlaylists.ts | 5 +- frontend/src/hooks/useDomains.ts | 5 +- frontend/src/hooks/useDownload.ts | 97 ++-- frontend/src/hooks/useFavorites.ts | 44 +- frontend/src/hooks/useFilterDropdowns.ts | 11 +- frontend/src/hooks/useGames.test.tsx | 45 +- frontend/src/hooks/useGames.ts | 8 +- frontend/src/hooks/useLazyLoad.tsx | 2 +- frontend/src/hooks/usePlayTracking.ts | 35 +- frontend/src/hooks/usePlaylists.ts | 30 +- frontend/src/hooks/useRecentGames.ts | 6 +- frontend/src/hooks/useRoles.ts | 3 +- frontend/src/hooks/useSharedAccessToken.ts | 51 +- frontend/src/hooks/useStatistics.ts | 2 +- frontend/src/hooks/useSwipeGesture.ts | 16 +- frontend/src/hooks/useUserPlaylists.ts | 31 +- frontend/src/hooks/useUsers.ts | 12 +- frontend/src/index.css | 9 +- frontend/src/lib/api.ts | 214 +++++--- frontend/src/lib/api/activities.ts | 11 +- frontend/src/lib/api/client.ts | 5 +- frontend/src/lib/api/jobs.ts | 5 +- frontend/src/lib/api/settings.ts | 7 +- frontend/src/lib/api/sharedPlaylists.ts | 28 +- frontend/src/lib/api/userPlaylists.ts | 25 +- frontend/src/lib/cron-utils.ts | 30 +- frontend/src/lib/date-utils.ts | 4 +- frontend/src/lib/playlistIcons.ts | 10 +- frontend/src/lib/utils.ts | 6 +- frontend/src/lib/validateIcons.ts | 4 +- frontend/src/main.tsx | 6 +- frontend/src/store/auth.ts | 18 +- frontend/src/store/sharedAccess.ts | 2 +- frontend/src/store/theme.ts | 55 +- frontend/src/store/ui.ts | 6 +- frontend/src/test/mocks/handlers.ts | 9 +- frontend/src/test/test-utils.tsx | 25 +- frontend/src/types/game.ts | 14 +- frontend/src/utils/activityUtils.ts | 6 +- frontend/src/utils/errorUtils.ts | 5 +- frontend/src/views/ActivitiesView.tsx | 4 +- frontend/src/views/DashboardView.tsx | 184 +++---- frontend/src/views/FavoritesView.tsx | 37 +- frontend/src/views/GameDetailView.tsx | 377 ++++++------- frontend/src/views/GamePlayerView.tsx | 46 +- frontend/src/views/HTML5GamesView.tsx | 8 +- frontend/src/views/JobsView.tsx | 38 +- frontend/src/views/MaintenancePage.tsx | 29 +- frontend/src/views/PlaylistDetailView.tsx | 9 +- frontend/src/views/PlaylistsView.tsx | 211 ++++---- frontend/src/views/RolesView.tsx | 10 +- frontend/src/views/SettingsView.tsx | 162 +++--- frontend/src/views/SharedPlaylistView.tsx | 17 +- frontend/src/views/UnauthorizedView.tsx | 18 +- frontend/src/views/UserPlaylistDetailView.tsx | 27 +- frontend/src/views/UsersView.tsx | 10 +- frontend/tailwind.config.js | 238 ++++----- frontend/vite.config.ts | 48 +- game-service/.eslintrc.json | 16 +- game-service/README.md | 27 +- game-service/src/config.ts | 2 +- game-service/src/gamezipserver.ts | 29 +- game-service/src/http-proxy-server.ts | 12 +- game-service/src/index.ts | 10 +- game-service/src/legacy-server.ts | 14 +- game-service/src/mimeTypes.ts | 4 +- game-service/src/proxy-request-handler.ts | 8 +- game-service/src/utils/htmlInjector.ts | 21 +- game-service/src/utils/logger.ts | 34 +- game-service/src/utils/pathSecurity.test.ts | 11 +- game-service/src/utils/pathSecurity.ts | 6 +- game-service/src/validation/schemas.ts | 12 +- game-service/src/zip-manager.ts | 12 +- game-service/vitest.config.ts | 7 +- 396 files changed, 11050 insertions(+), 8820 deletions(-) delete mode 100644 frontend/scripts/check-bundle-size.js diff --git a/CLAUDE.md b/CLAUDE.md index 58a6c5d..99abada 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -361,8 +361,8 @@ LOG_LEVEL=info > stored in the `system_settings` table as `metadata.flashpoint_edition` and > `metadata.flashpoint_version`. -See `backend/.env.example` for complete configuration options including -rate limiting, SQLite optimization, and OpenTelemetry settings. +See `backend/.env.example` for complete configuration options including rate +limiting, SQLite optimization, and OpenTelemetry settings. ### Game Service (.env) @@ -698,8 +698,8 @@ without extra API calls. **Change:** Replaced the `FLASHPOINT_EDITION` environment variable with automatic detection from `{FLASHPOINT_PATH}/version.txt`. Edition and version are held in the backend `config` object — **not** stored in the -`system_settings` database table. Backend services read `config` directly. -The frontend receives edition/version via `/api/settings/public` (injected from +`system_settings` database table. Backend services read `config` directly. The +frontend receives edition/version via `/api/settings/public` (injected from `config`). **How it works:** @@ -712,7 +712,8 @@ The frontend receives edition/version via `/api/settings/public` (injected from import and read `config` directly - The `/api/settings/public` endpoint injects edition/version from `config` into the response (same pattern as `homeRecentHours`) -- Frontend uses the `usePublicSettings()` hook (already cached, no extra request) +- Frontend uses the `usePublicSettings()` hook (already cached, no extra + request) - Falls back to `'infinity'` if `version.txt` is missing or unparseable **Files changed:** @@ -780,10 +781,14 @@ migrations with checksums and execution times. **After ANY code change, always:** -1. Verify no build errors: `npm run typecheck` and `npm run build` -2. Check if documentation needs updates -3. Ask the user if relevant documentation should be updated -4. Update the following docs as needed: +1. **Run Prettier** on all new and modified files: + `npx prettier --write ...` When using sub-agents (Task tool), + instruct each agent to run Prettier on every file it creates or modifies + before finishing. +2. Verify no build errors: `npm run typecheck` and `npm run build` +3. Check if documentation needs updates +4. Ask the user if relevant documentation should be updated +5. Update the following docs as needed: - Architecture changes → `docs/02-architecture/` - New/modified API endpoints → `docs/06-api-reference/` - Database schema changes → `docs/12-reference/database-schema-reference.md` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4cd43b0..256d80f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,7 @@ # Contributing to Flashpoint Web -Thank you for your interest in contributing to Flashpoint Web! This document provides guidelines and instructions for contributing to this project. +Thank you for your interest in contributing to Flashpoint Web! This document +provides guidelines and instructions for contributing to this project. ## Table of Contents @@ -19,7 +20,8 @@ Thank you for your interest in contributing to Flashpoint Web! This document pro ### Our Pledge -We are committed to providing a welcoming and inspiring community for all. By participating in this project, you agree to: +We are committed to providing a welcoming and inspiring community for all. By +participating in this project, you agree to: - Be respectful and inclusive - Accept constructive criticism gracefully @@ -90,14 +92,17 @@ cd ../frontend && npm test cd ../game-service && npm test ``` -For detailed setup instructions, see [Setup Guide](docs/08-development/setup-guide.md). +For detailed setup instructions, see +[Setup Guide](docs/08-development/setup-guide.md). ## How to Contribute ### Reporting Bugs Before reporting a bug: -1. Search [existing issues](https://github.com/OWNER/flashpoint-web/issues) to avoid duplicates + +1. Search [existing issues](https://github.com/OWNER/flashpoint-web/issues) to + avoid duplicates 2. Use the [Bug Report template](.github/ISSUE_TEMPLATE/bug_report.md) 3. Include: - Clear description of the issue @@ -125,6 +130,7 @@ Before reporting a bug: ### First-Time Contributors Look for issues labeled: + - `good first issue` - Simple issues for newcomers - `help wanted` - Issues where we need community help - `documentation` - Documentation improvements @@ -134,12 +140,14 @@ Look for issues labeled: ### Before Submitting 1. **Sync with upstream**: + ```bash git fetch upstream git rebase upstream/master ``` 2. **Run all checks**: + ```bash npm run typecheck npm run format:check @@ -161,6 +169,7 @@ Look for issues labeled: ### Branch Naming Use descriptive branch names: + ``` feature/add-playlist-export fix/game-search-pagination @@ -285,16 +294,16 @@ footer (optional) ### Types -| Type | Description | -|------|-------------| -| `feat` | New feature | -| `fix` | Bug fix | -| `docs` | Documentation changes | -| `style` | Code style changes (formatting, semicolons) | -| `refactor` | Code refactoring | -| `test` | Adding or updating tests | -| `chore` | Maintenance tasks | -| `perf` | Performance improvements | +| Type | Description | +| ---------- | ------------------------------------------- | +| `feat` | New feature | +| `fix` | Bug fix | +| `docs` | Documentation changes | +| `style` | Code style changes (formatting, semicolons) | +| `refactor` | Code refactoring | +| `test` | Adding or updating tests | +| `chore` | Maintenance tasks | +| `perf` | Performance improvements | ### Examples @@ -376,6 +385,7 @@ describe('GameService', () => { ### When to Update Docs Update documentation when you: + - Add new features - Change API endpoints - Modify configuration options @@ -453,6 +463,7 @@ flashpoint-web/ ## Recognition Contributors are recognized in: + - GitHub contributors list - Release notes for significant contributions - README acknowledgments section @@ -461,16 +472,17 @@ Contributors are recognized in: ## Quick Reference -| Task | Command | -|------|---------| -| Install dependencies | `npm run install:all` | -| Start dev servers | `npm run dev` | -| Type check | `npm run typecheck` | -| Format code | `npm run format` | -| Run backend tests | `cd backend && npm test` | -| Run frontend tests | `cd frontend && npm test` | -| Build all | `npm run build` | +| Task | Command | +| -------------------- | ------------------------- | +| Install dependencies | `npm run install:all` | +| Start dev servers | `npm run dev` | +| Type check | `npm run typecheck` | +| Format code | `npm run format` | +| Run backend tests | `cd backend && npm test` | +| Run frontend tests | `cd frontend && npm test` | +| Build all | `npm run build` | --- -Thank you for contributing to Flashpoint Web! Your efforts help preserve gaming history for future generations. +Thank you for contributing to Flashpoint Web! Your efforts help preserve gaming +history for future generations. diff --git a/backend/.eslintrc.json b/backend/.eslintrc.json index 7deb6b4..543997e 100644 --- a/backend/.eslintrc.json +++ b/backend/.eslintrc.json @@ -6,15 +6,15 @@ "project": true }, "plugins": ["@typescript-eslint"], - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended" - ], + "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], "rules": { - "@typescript-eslint/no-unused-vars": ["error", { - "argsIgnorePattern": "^_", - "varsIgnorePattern": "^_" - }], + "@typescript-eslint/no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_" + } + ], "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/explicit-module-boundary-types": "off", diff --git a/backend/README.md b/backend/README.md index 56dea66..fb02554 100644 --- a/backend/README.md +++ b/backend/README.md @@ -54,9 +54,11 @@ src/ ## Database Uses sql.js (pure JavaScript SQLite) for direct read-only access to: + - `D:\Flashpoint\Data\flashpoint.sqlite` -**No native compilation required!** sql.js is a WebAssembly-based SQLite implementation that works without Visual Studio or build tools. +**No native compilation required!** sql.js is a WebAssembly-based SQLite +implementation that works without Visual Studio or build tools. Schema includes tables for games, platforms, tags, and more. @@ -73,10 +75,12 @@ npm run lint # ESLint ## Environment Variables Required: + - `FLASHPOINT_PATH` - Path to Flashpoint installation - `FLASHPOINT_DB_PATH` - Path to SQLite database Optional: + - `PORT` - Server port (default: 3100) - `NODE_ENV` - Environment (development/production) - `GAME_SERVER_URL` - Game Server URL (default: http://localhost:22500) diff --git a/backend/scripts/fix-absolute-paths.ts b/backend/scripts/fix-absolute-paths.ts index e991e7a..dc9ce0d 100644 --- a/backend/scripts/fix-absolute-paths.ts +++ b/backend/scripts/fix-absolute-paths.ts @@ -77,9 +77,7 @@ async function fixAbsolutePaths() { } // Filter entries with absolute paths - const absolutePathEntries = rows.filter(row => - row.path && isAbsolutePath(row.path) - ); + const absolutePathEntries = rows.filter((row) => row.path && isAbsolutePath(row.path)); console.log(`Found ${absolutePathEntries.length} entries with absolute paths\n`); @@ -155,7 +153,6 @@ async function fixAbsolutePaths() { console.log(); console.log('✓ All paths have been converted to relative format'); console.log('✓ Your games are now fully compatible with Flashpoint Launcher'); - } catch (error) { console.error('\n✗ Migration failed:', error); process.exit(1); diff --git a/backend/scripts/kill-port.js b/backend/scripts/kill-port.js index cd8fccb..c4112d8 100644 --- a/backend/scripts/kill-port.js +++ b/backend/scripts/kill-port.js @@ -56,7 +56,6 @@ try { } console.log(`\n✅ Port ${port} should now be free!`); - } catch (error) { if (error.status === 1 && error.stderr === '') { // findstr returns exit code 1 when nothing is found diff --git a/backend/src/config.ts b/backend/src/config.ts index c06e1f0..dde5bcf 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -20,8 +20,8 @@ function getJwtSecret(): string { if (process.env.NODE_ENV === 'production') { throw new Error( 'FATAL: JWT_SECRET environment variable is required in production.\n' + - 'Generate a secure secret: openssl rand -base64 64\n' + - 'Set it in your .env file or environment variables.' + 'Generate a secure secret: openssl rand -base64 64\n' + + 'Set it in your .env file or environment variables.' ); } @@ -106,7 +106,8 @@ export const config = { // In Docker: Use relative paths through nginx proxy (/game-proxy) // In local dev: Use direct localhost URLs (http://localhost:22500) // Can be overridden with GAME_SERVICE_EXTERNAL_URL env var - gameServiceExternalUrl: process.env.GAME_SERVICE_EXTERNAL_URL || + gameServiceExternalUrl: + process.env.GAME_SERVICE_EXTERNAL_URL || (process.env.NODE_ENV === 'production' ? '/game-proxy' : `http://localhost:22500`), // Frontend domain (used for CORS and share URL generation) @@ -120,7 +121,9 @@ export const config = { // In Docker (production): defaults to /app/logs/backend.log // In local dev: no file logging unless LOG_FILE is explicitly set logLevel: process.env.LOG_LEVEL || 'info', - logFile: process.env.LOG_FILE || (process.env.NODE_ENV === 'production' ? '/app/logs/backend.log' : undefined), + logFile: + process.env.LOG_FILE || + (process.env.NODE_ENV === 'production' ? '/app/logs/backend.log' : undefined), // User Database // In Docker (production): /app/data/user.db @@ -141,7 +144,7 @@ export const config = { // When enabled, copies flashpoint.sqlite to local storage for faster access // Stored in /app/data alongside user.db enableLocalDbCopy: process.env.ENABLE_LOCAL_DB_COPY === 'true', - localDbPath: '/app/data/flashpoint.sqlite', // Only used when enableLocalDbCopy is true + localDbPath: '/app/data/flashpoint.sqlite', // Only used when enableLocalDbCopy is true // SQLite Performance Tuning sqliteCacheSize: parseInt(process.env.SQLITE_CACHE_SIZE || '-64000', 10), // Negative = KB, -64000 = 64MB @@ -156,7 +159,7 @@ export const config = { flashpointEdition: flashpointVersion.edition, // Full version string from version.txt (e.g., "Flashpoint 14.0.3 Infinity - Kingfisher") - flashpointVersionString: flashpointVersion.versionString + flashpointVersionString: flashpointVersion.versionString, }; /** diff --git a/backend/src/jobs/MetadataSyncJob.ts b/backend/src/jobs/MetadataSyncJob.ts index dad4041..2747028 100644 --- a/backend/src/jobs/MetadataSyncJob.ts +++ b/backend/src/jobs/MetadataSyncJob.ts @@ -54,17 +54,16 @@ export class MetadataSyncJob { logger.info( `[MetadataSyncJob] Sync completed successfully - ` + - `Games: ${result.gamesUpdated}, ` + - `Tags: ${result.tagsUpdated}, ` + - `Platforms: ${result.platformsUpdated}, ` + - `Deleted: ${result.gamesDeleted}` + `Games: ${result.gamesUpdated}, ` + + `Tags: ${result.tagsUpdated}, ` + + `Platforms: ${result.platformsUpdated}, ` + + `Deleted: ${result.gamesDeleted}` ); return message; } else { logger.error(`[MetadataSyncJob] Sync failed: ${result.error}`); throw new Error(result.error || 'Metadata sync failed'); } - } catch (error) { logger.error('[MetadataSyncJob] Error running metadata sync job:', error); throw error; // Re-throw so JobScheduler marks execution as failed diff --git a/backend/src/jobs/RuffleUpdateJob.ts b/backend/src/jobs/RuffleUpdateJob.ts index 8ae705c..c1ddab6 100644 --- a/backend/src/jobs/RuffleUpdateJob.ts +++ b/backend/src/jobs/RuffleUpdateJob.ts @@ -46,9 +46,7 @@ export class RuffleUpdateJob { const result = await this.ruffleService.updateRuffle(); if (result.success) { - logger.info( - `[RuffleUpdateJob] Update completed successfully - Version: ${result.version}` - ); + logger.info(`[RuffleUpdateJob] Update completed successfully - Version: ${result.version}`); return `Ruffle updated to ${result.version}`; } else { logger.error(`[RuffleUpdateJob] Update failed: ${result.message}`); diff --git a/backend/src/middleware/MAINTENANCE_MODE.md b/backend/src/middleware/MAINTENANCE_MODE.md index 13c3c6a..726bd6d 100644 --- a/backend/src/middleware/MAINTENANCE_MODE.md +++ b/backend/src/middleware/MAINTENANCE_MODE.md @@ -2,7 +2,9 @@ ## Overview -The maintenance mode middleware provides a way to temporarily restrict access to the application while allowing administrators to continue working. This is useful for: +The maintenance mode middleware provides a way to temporarily restrict access to +the application while allowing administrators to continue working. This is +useful for: - Performing database migrations - Applying critical updates @@ -11,25 +13,33 @@ The maintenance mode middleware provides a way to temporarily restrict access to ## How It Works -The middleware uses a **simplified 4-step approach** to control access during maintenance: +The middleware uses a **simplified 4-step approach** to control access during +maintenance: ### Prerequisites -**IMPORTANT:** The `softAuth` middleware MUST be applied globally **before** this middleware. `softAuth` populates `req.user` with user data if a valid token exists, without throwing errors. This allows the maintenance middleware to identify admin users. +**IMPORTANT:** The `softAuth` middleware MUST be applied globally **before** +this middleware. `softAuth` populates `req.user` with user data if a valid token +exists, without throwing errors. This allows the maintenance middleware to +identify admin users. **Middleware order in server.ts:** + ```typescript -app.use(softAuth); // 1. Populate req.user -app.use(maintenanceMode); // 2. Check maintenance and admin status +app.use(softAuth); // 1. Populate req.user +app.use(maintenanceMode); // 2. Check maintenance and admin status ``` ### Step 1: Allow Minimal Public Paths -**What:** A minimal set of endpoints that must work for admin to LOGIN during maintenance. +**What:** A minimal set of endpoints that must work for admin to LOGIN during +maintenance. -**Why:** Admin needs to be able to log in to access the system and disable maintenance mode. +**Why:** Admin needs to be able to log in to access the system and disable +maintenance mode. **Minimal public paths:** + - `/health` - Health check endpoint (monitoring) - `/api/auth/login` - Admin needs to login - `/api/auth/refresh` - Token refresh @@ -48,11 +58,14 @@ app.use(maintenanceMode); // 2. Check maintenance and admin status **What:** Users with `settings.update` permission can access **ALL endpoints**. **How it works:** -- `req.user` is populated by `softAuth` middleware (applied globally before this) + +- `req.user` is populated by `softAuth` middleware (applied globally before + this) - Check if `req.user.permissions` includes `'settings.update'` - If yes → **ALLOW EVERYTHING** (all features, all APIs) **Admin actions allowed during maintenance:** + - ✅ Browse games, playlists, platforms - ✅ View and modify all settings - ✅ Manage users and roles @@ -61,9 +74,11 @@ app.use(maintenanceMode); // 2. Check maintenance and admin status ### Step 4: Block Everyone Else -**What:** Non-admin users and guests are blocked from ALL endpoints (except public paths). +**What:** Non-admin users and guests are blocked from ALL endpoints (except +public paths). **Response:** + ```json { "error": "Service Unavailable", @@ -74,6 +89,7 @@ app.use(maintenanceMode); // 2. Check maintenance and admin status ``` **HTTP Headers:** + - Status: `503 Service Unavailable` - `Retry-After: 3600` (retry after 1 hour) @@ -82,23 +98,32 @@ app.use(maintenanceMode); // 2. Check maintenance and admin status The middleware provides detailed logging to help troubleshoot issues: ### Info Logs (admin access): -- `[Maintenance] Admin accessing GET /api/games` - Admin accessing endpoint during maintenance + +- `[Maintenance] Admin accessing GET /api/games` - Admin accessing + endpoint during maintenance ### Warning Logs (when requests are blocked): -- `[Maintenance] Blocked GET /api/games from user john (IP: 127.0.0.1)` - Authenticated user blocked -- `[Maintenance] Blocked GET /api/games from unauthenticated user (IP: 127.0.0.1)` - Guest blocked + +- `[Maintenance] Blocked GET /api/games from user john (IP: 127.0.0.1)` - + Authenticated user blocked +- `[Maintenance] Blocked GET /api/games from unauthenticated user (IP: 127.0.0.1)` - + Guest blocked ### Error Logs (critical failures): -- `[Maintenance] Critical error in maintenance middleware: ` - Middleware error (request blocked) + +- `[Maintenance] Critical error in maintenance middleware: ` - Middleware + error (request blocked) ## Enabling Maintenance Mode ### Via Settings UI (Admin only): + 1. Navigate to Settings → App Settings 2. Toggle "Maintenance Mode" on 3. Confirm the change ### Via API: + ```bash curl -X PATCH http://localhost:3001/api/settings/app \ -H "Authorization: Bearer " \ @@ -107,6 +132,7 @@ curl -X PATCH http://localhost:3001/api/settings/app \ ``` ### Via Database: + ```sql UPDATE system_settings SET value = '1' @@ -116,12 +142,14 @@ WHERE key = 'app.maintenance_mode'; ## Testing Maintenance Mode ### Test as Admin: + 1. Enable maintenance mode 2. Verify you can still access all pages 3. Check logs - should see debug messages about admin access 4. Disable maintenance mode ### Test as Regular User: + 1. Enable maintenance mode (as admin) 2. Log in as regular user in a different browser/incognito 3. Verify API requests are blocked with 503 @@ -129,6 +157,7 @@ WHERE key = 'app.maintenance_mode'; 5. Disable maintenance mode (as admin) ### Test as Guest: + 1. Enable maintenance mode (as admin) 2. Open site in incognito/private window (not logged in) 3. Verify API requests are blocked with 503 @@ -139,17 +168,21 @@ WHERE key = 'app.maintenance_mode'; ### Issue: "Admin sees blocked requests in logs" -**Cause:** Some browser requests (prefetch, favicon, extensions) may not include the Authorization header. +**Cause:** Some browser requests (prefetch, favicon, extensions) may not include +the Authorization header. -**Solution:** This is normal. Only API requests to non-whitelisted endpoints need admin auth. +**Solution:** This is normal. Only API requests to non-whitelisted endpoints +need admin auth. ### Issue: "Can't disable maintenance mode" **Cause:** Admin token may have expired or `/api/settings` is being blocked. **Solution:** + 1. Log out and log back in to get fresh token 2. Or update database directly: + ```sql UPDATE system_settings SET value = '0' WHERE key = 'app.maintenance_mode'; ``` @@ -164,21 +197,26 @@ UPDATE system_settings SET value = '0' WHERE key = 'app.maintenance_mode'; ### Fail-Closed Strategy (Secure by Default) -**IMPORTANT:** If the middleware encounters an error (e.g., database connection fails), it **blocks the request** rather than allowing it through. +**IMPORTANT:** If the middleware encounters an error (e.g., database connection +fails), it **blocks the request** rather than allowing it through. **Why fail-closed:** + - Maintenance mode is a security-critical feature - Errors during maintenance could indicate database issues - Safer to block requests than expose potentially broken systems - Admin can still disable maintenance via database if needed **Impact:** + - If settings service fails → all requests blocked (except public paths) - Better to be overly cautious during maintenance ### Token Validation -The middleware relies on `softAuth` middleware (applied globally before it) which: +The middleware relies on `softAuth` middleware (applied globally before it) +which: + - Extracts JWT from Authorization header - Verifies token signature and expiration - Populates `req.user` with user data @@ -188,16 +226,20 @@ The middleware relies on `softAuth` middleware (applied globally before it) whic ### Admin Detection -Only users with the `settings.update` permission can bypass maintenance mode. This is typically only the "admin" role. +Only users with the `settings.update` permission can bypass maintenance mode. +This is typically only the "admin" role. **Permission check:** + ```typescript const isAdmin = req.user?.permissions?.includes('settings.update'); ``` ### Singleton Service Instance -The middleware uses a **shared singleton** instance of `CachedSystemSettingsService`: +The middleware uses a **shared singleton** instance of +`CachedSystemSettingsService`: + - Prevents per-request service instantiation - Shares cache across all requests - Better performance and memory usage @@ -207,7 +249,8 @@ The middleware uses a **shared singleton** instance of `CachedSystemSettingsServ ### Optimizations - **Cache TTL:** 60 seconds (settings cached in singleton service) -- **Single token verification:** Token verified once by `softAuth`, result reused +- **Single token verification:** Token verified once by `softAuth`, result + reused - **Early returns:** Requests exit as soon as they're allowed - **Singleton service:** Shared instance prevents per-request instantiation - **Minimal processing:** Simple permission checks, no heavy computations @@ -223,9 +266,11 @@ The middleware uses a **shared singleton** instance of `CachedSystemSettingsServ ### What Changed -The maintenance mode middleware was recently **completely revamped** to fix critical issues: +The maintenance mode middleware was recently **completely revamped** to fix +critical issues: **Old architecture issues:** + - ❌ Duplicate token verification (performance cost) - ❌ Per-request service instantiation - ❌ Fail-open security flaw @@ -233,6 +278,7 @@ The maintenance mode middleware was recently **completely revamped** to fix crit - ❌ Admin users getting blocked **New architecture benefits:** + - ✅ Single token verification (via `softAuth` middleware) - ✅ Singleton service instance (shared cache) - ✅ Fail-closed security (secure by default) @@ -251,6 +297,7 @@ If you're upgrading from the old version: ## Future Enhancements Potential improvements: + - Configurable maintenance message (via system settings) - Scheduled maintenance windows - Read-only mode (allow reads, block writes) diff --git a/backend/src/middleware/activityLogger.ts b/backend/src/middleware/activityLogger.ts index d02a583..558d4aa 100644 --- a/backend/src/middleware/activityLogger.ts +++ b/backend/src/middleware/activityLogger.ts @@ -39,7 +39,7 @@ export const logActivity = ( resourceId, details, ipAddress: req.ip, - userAgent: req.headers['user-agent'] + userAgent: req.headers['user-agent'], }); } catch (error) { // Log error but don't fail request diff --git a/backend/src/middleware/asyncHandler.ts b/backend/src/middleware/asyncHandler.ts index f139c8e..a33f9dd 100644 --- a/backend/src/middleware/asyncHandler.ts +++ b/backend/src/middleware/asyncHandler.ts @@ -50,8 +50,4 @@ export const asyncHandler = ( * router.get('/games', asyncHandler(getGames)); * ``` */ -export type AsyncRouteHandler = ( - req: Request, - res: Response, - next: NextFunction -) => Promise; +export type AsyncRouteHandler = (req: Request, res: Response, next: NextFunction) => Promise; diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index efa6afe..e4bb8d8 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -68,7 +68,7 @@ export const optionalAuth = async (req: Request, res: Response, next: NextFuncti username: 'guest', email: '', role: 'guest', - permissions: ['games.read', 'playlists.read'] + permissions: ['games.read', 'playlists.read'], }; } @@ -131,7 +131,12 @@ export const sharedAccessAuth = asyncHandler( // Validate the shareToken is still valid const playlist = playlistService.getPlaylistByShareToken(payload.shareToken); if (!playlist) { - throw new AppError(401, 'Shared access token invalid or expired', true, 'SHARED_ACCESS_INVALID'); + throw new AppError( + 401, + 'Shared access token invalid or expired', + true, + 'SHARED_ACCESS_INVALID' + ); } req.sharedAccess = payload; @@ -162,7 +167,7 @@ export const sharedAccessAuth = asyncHandler( username: 'guest', email: '', role: 'guest', - permissions: ['games.read', 'playlists.read', 'games.play'] + permissions: ['games.read', 'playlists.read', 'games.play'], }; next(); @@ -193,7 +198,12 @@ export const validateSharedGameAccess = (gameIdParam: string = 'id') => { ); if (!isInPlaylist) { - throw new AppError(403, 'Game not accessible via this shared playlist', true, 'GAME_NOT_IN_SHARED_PLAYLIST'); + throw new AppError( + 403, + 'Game not accessible via this shared playlist', + true, + 'GAME_NOT_IN_SHARED_PLAYLIST' + ); } return next(); diff --git a/backend/src/middleware/dateValidation.ts b/backend/src/middleware/dateValidation.ts index 2e81cbf..81c4fd2 100644 --- a/backend/src/middleware/dateValidation.ts +++ b/backend/src/middleware/dateValidation.ts @@ -43,14 +43,14 @@ export function validateDateRange( if (isNaN(startDateObj.getTime()) || isNaN(endDateObj.getTime())) { return res.status(400).json({ success: false, - error: 'Invalid date format' + error: 'Invalid date format', }); } if (endDateObj < startDateObj) { return res.status(400).json({ success: false, - error: 'End date must be after or equal to start date' + error: 'End date must be after or equal to start date', }); } } diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts index 35131e6..82019d9 100644 --- a/backend/src/middleware/errorHandler.ts +++ b/backend/src/middleware/errorHandler.ts @@ -30,10 +30,10 @@ function sanitizeBody(body: any): any { 'refreshToken', 'secret', 'apiKey', - 'accessToken' + 'accessToken', ]; - sensitiveFields.forEach(field => { + sensitiveFields.forEach((field) => { if (sanitized[field]) { sanitized[field] = '[REDACTED]'; } @@ -53,15 +53,15 @@ export function errorHandler( statusCode: err.statusCode, path: req.path, method: req.method, - stack: err.stack + stack: err.stack, }); return res.status(err.statusCode).json({ error: { message: err.message, statusCode: err.statusCode, - ...(err.code && { code: err.code }) - } + ...(err.code && { code: err.code }), + }, }); } @@ -74,14 +74,14 @@ export function errorHandler( method: req.method, body: sanitizeBody(req.body), query: req.query, - params: req.params + params: req.params, }); // Return generic error message to client (prevent information leakage) res.status(500).json({ error: { message: 'Internal server error', - statusCode: 500 - } + statusCode: 500, + }, }); } diff --git a/backend/src/middleware/featureFlags.ts b/backend/src/middleware/featureFlags.ts index 7577695..9cb54bc 100644 --- a/backend/src/middleware/featureFlags.ts +++ b/backend/src/middleware/featureFlags.ts @@ -29,7 +29,10 @@ export function requireFeature(featureKey: string) { logger.warn( `[FeatureFlag] Feature '${featureKey}' is disabled, blocked ${req.method} ${req.path} from ${req.user?.username || 'guest'} (IP: ${req.ip})` ); - throw new AppError(403, `This feature is currently disabled. Please contact your administrator.`); + throw new AppError( + 403, + `This feature is currently disabled. Please contact your administrator.` + ); } next(); diff --git a/backend/src/middleware/maintenanceMode.ts b/backend/src/middleware/maintenanceMode.ts index af64ce7..d16813e 100644 --- a/backend/src/middleware/maintenanceMode.ts +++ b/backend/src/middleware/maintenanceMode.ts @@ -8,14 +8,14 @@ const systemSettings = new CachedSystemSettingsService(); // MINIMAL whitelist - only endpoints needed for admin to LOGIN // Everything else requires admin authentication const MINIMAL_PUBLIC_PATHS = [ - '/health', // Health checks for monitoring - '/api/auth/login', // Admin needs to login - '/api/auth/register', // Initial admin setup (first user) - '/api/auth/setup-status', // Check if system needs initial setup - '/api/auth/refresh', // Token refresh - '/api/settings/public', // UI needs public settings - '/proxy', // Game images, logos, screenshots, and game files - '/api/playlists/shared', // Anonymous shared playlist access (bypasses guest access settings) + '/health', // Health checks for monitoring + '/api/auth/login', // Admin needs to login + '/api/auth/register', // Initial admin setup (first user) + '/api/auth/setup-status', // Check if system needs initial setup + '/api/auth/refresh', // Token refresh + '/api/settings/public', // UI needs public settings + '/proxy', // Game images, logos, screenshots, and game files + '/api/playlists/shared', // Anonymous shared playlist access (bypasses guest access settings) ]; /** @@ -36,12 +36,16 @@ const MINIMAL_PUBLIC_PATHS = [ * 3. If enabled: only allow admins through, block everyone else * 4. If disabled: allow everyone (normal operation) */ -export async function maintenanceMode(req: Request, res: Response, next: NextFunction): Promise { +export async function maintenanceMode( + req: Request, + res: Response, + next: NextFunction +): Promise { try { // Step 1: Check if path is in minimal public whitelist // These paths must work for admin to LOGIN during maintenance - const isPublicPath = MINIMAL_PUBLIC_PATHS.some(path => - req.path === path || req.path.startsWith(path + '/') + const isPublicPath = MINIMAL_PUBLIC_PATHS.some( + (path) => req.path === path || req.path.startsWith(path + '/') ); if (isPublicPath) { @@ -73,27 +77,20 @@ export async function maintenanceMode(req: Request, res: Response, next: NextFun if (isAdmin) { // Admin user - ALLOW EVERYTHING (all endpoints, all features) // user is guaranteed to exist if isAdmin is true - logger.info( - `[Maintenance] Admin ${user!.username} accessing ${req.method} ${req.path}` - ); + logger.info(`[Maintenance] Admin ${user!.username} accessing ${req.method} ${req.path}`); return next(); } // Step 4: Non-admin user or guest - BLOCK EVERYTHING const userInfo = user ? `user ${user.username}` : 'unauthenticated user'; - logger.warn( - `[Maintenance] Blocked ${req.method} ${req.path} from ${userInfo} (IP: ${req.ip})` - ); - - return res.status(503) - .set('Retry-After', '3600') - .json({ - error: 'Service Unavailable', - message: 'The application is currently undergoing maintenance. Please try again later.', - maintenanceMode: true, - retryAfter: 3600, - }); + logger.warn(`[Maintenance] Blocked ${req.method} ${req.path} from ${userInfo} (IP: ${req.ip})`); + return res.status(503).set('Retry-After', '3600').json({ + error: 'Service Unavailable', + message: 'The application is currently undergoing maintenance. Please try again later.', + maintenanceMode: true, + retryAfter: 3600, + }); } catch (error) { logger.error('[Maintenance] Critical error in maintenance middleware:', error); diff --git a/backend/src/middleware/performanceTracking.ts b/backend/src/middleware/performanceTracking.ts index 0e65c03..e186d31 100644 --- a/backend/src/middleware/performanceTracking.ts +++ b/backend/src/middleware/performanceTracking.ts @@ -34,11 +34,13 @@ export function performanceTracking(req: Request, res: Response, next: NextFunct * - /api/users/789 → /api/users/:id */ function normalizePath(path: string): string { - return path - // Replace UUIDs - .replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '/:id') - // Replace numeric IDs - .replace(/\/\d+/g, '/:id') - // Replace alphanumeric IDs (game IDs, share tokens) - .replace(/\/[a-zA-Z0-9_-]{8,}/g, '/:id'); + return ( + path + // Replace UUIDs + .replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '/:id') + // Replace numeric IDs + .replace(/\/\d+/g, '/:id') + // Replace alphanumeric IDs (game IDs, share tokens) + .replace(/\/[a-zA-Z0-9_-]{8,}/g, '/:id') + ); } diff --git a/backend/src/middleware/rbac.ts b/backend/src/middleware/rbac.ts index c9a8743..649fd64 100644 --- a/backend/src/middleware/rbac.ts +++ b/backend/src/middleware/rbac.ts @@ -14,9 +14,7 @@ export const requirePermission = (...permissions: string[]) => { const userPermissions = req.user.permissions || []; // Check if user has any of the required permissions - const hasPermission = permissions.some(permission => - userPermissions.includes(permission) - ); + const hasPermission = permissions.some((permission) => userPermissions.includes(permission)); if (!hasPermission) { return next(new AppError(403, 'Insufficient permissions')); diff --git a/backend/src/middleware/requestTimeout.ts b/backend/src/middleware/requestTimeout.ts index 72f7795..28f855e 100644 --- a/backend/src/middleware/requestTimeout.ts +++ b/backend/src/middleware/requestTimeout.ts @@ -50,7 +50,7 @@ export function requestTimeout(timeoutMs: number = DEFAULT_TIMEOUT_MS) { timeout: `${timeoutMs}ms`, elapsed: `${elapsed}ms`, ip: req.ip, - userAgent: req.get('user-agent') + userAgent: req.get('user-agent'), }); res.status(408).json({ @@ -58,8 +58,8 @@ export function requestTimeout(timeoutMs: number = DEFAULT_TIMEOUT_MS) { message: 'Request timeout', statusCode: 408, timeout: timeoutMs, - details: 'The server took too long to respond. Please try again.' - } + details: 'The server took too long to respond. Please try again.', + }, }); hasResponded = true; @@ -71,17 +71,17 @@ export function requestTimeout(timeoutMs: number = DEFAULT_TIMEOUT_MS) { const originalJson = res.json.bind(res); const originalEnd = res.end.bind(res); - res.send = function(body?: any) { + res.send = function (body?: any) { clearTimeoutAndLog(); return originalSend(body); }; - res.json = function(body?: any) { + res.json = function (body?: any) { clearTimeoutAndLog(); return originalJson(body); }; - res.end = function(chunk?: any, encoding?: any, callback?: any) { + res.end = function (chunk?: any, encoding?: any, callback?: any) { clearTimeoutAndLog(); return originalEnd(chunk, encoding, callback); }; @@ -100,7 +100,7 @@ export function requestTimeout(timeoutMs: number = DEFAULT_TIMEOUT_MS) { url: req.url, elapsed: `${elapsed}ms`, timeout: `${timeoutMs}ms`, - percentage: `${Math.round((elapsed / timeoutMs) * 100)}%` + percentage: `${Math.round((elapsed / timeoutMs) * 100)}%`, }); } } @@ -115,7 +115,7 @@ export function requestTimeout(timeoutMs: number = DEFAULT_TIMEOUT_MS) { if (isTimedOut) { logger.info('[Request Aborted] Client closed connection after timeout', { method: req.method, - url: req.url + url: req.url, }); } } @@ -142,5 +142,5 @@ export const TimeoutConfig = { LONG: 120000, /** Extended timeout for batch operations (300s / 5 minutes) */ - EXTENDED: 300000 + EXTENDED: 300000, }; diff --git a/backend/src/middleware/validation.ts b/backend/src/middleware/validation.ts index ce1a497..d9ec6df 100644 --- a/backend/src/middleware/validation.ts +++ b/backend/src/middleware/validation.ts @@ -52,9 +52,7 @@ export function validateRequest( if (error instanceof z.ZodError) { // Extract first validation error message const firstError = error.errors[0]; - const message = firstError - ? `Validation error: ${firstError.message}` - : 'Validation error'; + const message = firstError ? `Validation error: ${firstError.message}` : 'Validation error'; return next(new AppError(400, message)); } @@ -104,9 +102,7 @@ export function validateMultiple(schemas: Partial { + private async handleFileRequest( + req: http.IncomingMessage, + res: http.ServerResponse + ): Promise { if (!req.url) { this.sendError(res, 400, 'Bad Request: No URL'); return; @@ -233,7 +236,9 @@ export class GameZipServer { const contentType = mimeLookup(ext) || 'application/octet-stream'; // Send file - logger.info(`[GameZipServer] ✓ Serving from ZIP ${result.mountId}: ${relPath} (${result.data.length} bytes)`); + logger.info( + `[GameZipServer] ✓ Serving from ZIP ${result.mountId}: ${relPath} (${result.data.length} bytes)` + ); // CORS headers if (this.settings.allowCrossDomain) { @@ -258,7 +263,7 @@ export class GameZipServer { private readBody(req: http.IncomingMessage): Promise { return new Promise((resolve, reject) => { let body = ''; - req.on('data', chunk => { + req.on('data', (chunk) => { body += chunk.toString(); }); req.on('end', () => { diff --git a/backend/src/proxy/legacy-server.ts b/backend/src/proxy/legacy-server.ts index 11ca26f..b3e9107 100644 --- a/backend/src/proxy/legacy-server.ts +++ b/backend/src/proxy/legacy-server.ts @@ -68,13 +68,16 @@ export class LegacyServer { * Build all path candidates in priority order * Replicates the path resolution algorithm from Go's ServeLegacy() */ - private buildPathCandidates(relPath: string, urlPath: string): Array<{ path: string; type: string }> { + private buildPathCandidates( + relPath: string, + urlPath: string + ): Array<{ path: string; type: string }> { const candidates: Array<{ path: string; type: string }> = []; // 1. Exact file paths (with and without query string) candidates.push({ path: path.join(this.settings.legacyHTDOCSPath, relPath), - type: 'exact' + type: 'exact', }); // If URL has query string, try without it @@ -82,7 +85,7 @@ export class LegacyServer { if (pathWithoutQuery !== relPath) { candidates.push({ path: path.join(this.settings.legacyHTDOCSPath, pathWithoutQuery), - type: 'exact-no-query' + type: 'exact-no-query', }); } @@ -90,13 +93,13 @@ export class LegacyServer { for (const override of this.settings.legacyOverridePaths) { candidates.push({ path: path.join(this.settings.legacyHTDOCSPath, override, relPath), - type: `override:${override}` + type: `override:${override}`, }); if (pathWithoutQuery !== relPath) { candidates.push({ path: path.join(this.settings.legacyHTDOCSPath, override, pathWithoutQuery), - type: `override:${override}-no-query` + type: `override:${override}-no-query`, }); } } @@ -105,13 +108,13 @@ export class LegacyServer { if (this.isScriptUrl(urlPath)) { candidates.push({ path: path.join(this.settings.legacyCGIBINPath, relPath), - type: 'cgi-bin' + type: 'cgi-bin', }); if (pathWithoutQuery !== relPath) { candidates.push({ path: path.join(this.settings.legacyCGIBINPath, pathWithoutQuery), - type: 'cgi-bin-no-query' + type: 'cgi-bin-no-query', }); } } @@ -120,14 +123,14 @@ export class LegacyServer { for (const ext of INDEX_EXTENSIONS) { candidates.push({ path: path.join(this.settings.legacyHTDOCSPath, relPath, `index.${ext}`), - type: `index:${ext}` + type: `index:${ext}`, }); // Index files in override paths for (const override of this.settings.legacyOverridePaths) { candidates.push({ path: path.join(this.settings.legacyHTDOCSPath, override, relPath, `index.${ext}`), - type: `index-override:${override}:${ext}` + type: `index-override:${override}:${ext}`, }); } } @@ -170,7 +173,7 @@ export class LegacyServer { return { data, contentType, - source: 'local-htdocs' + source: 'local-htdocs', }; } diff --git a/backend/src/proxy/mimeTypes.ts b/backend/src/proxy/mimeTypes.ts index 194306c..2193c87 100644 --- a/backend/src/proxy/mimeTypes.ts +++ b/backend/src/proxy/mimeTypes.ts @@ -175,7 +175,7 @@ export const CUSTOM_MIME_TYPES: { [key: string]: string } = { wrl: 'model/vrml', wvr: 'x-world/x-wvr', x3db: 'model/x3d+binary', - xvr: 'x-world/x-xvr' + xvr: 'x-world/x-xvr', }; // Standard MIME types for common web files @@ -230,7 +230,7 @@ const STANDARD_MIME_TYPES: { [key: string]: string } = { // Binary bin: 'application/octet-stream', exe: 'application/octet-stream', - dll: 'application/octet-stream' + dll: 'application/octet-stream', }; /** diff --git a/backend/src/proxy/proxy-request-handler.ts b/backend/src/proxy/proxy-request-handler.ts index 351b19d..a0663a3 100644 --- a/backend/src/proxy/proxy-request-handler.ts +++ b/backend/src/proxy/proxy-request-handler.ts @@ -72,7 +72,6 @@ export class ProxyRequestHandler { logger.info(`[ProxyHandler] ✓ Served from ${legacyResult.source}`); this.sendResponse(res, legacyResult.data, legacyResult.contentType, legacyResult.source); - } catch (error) { logger.error('[ProxyHandler] Error handling request:', error); @@ -119,7 +118,12 @@ export class ProxyRequestHandler { /** * Send successful response */ - private sendResponse(res: ServerResponse, data: Buffer, contentType: string, source: string): void { + private sendResponse( + res: ServerResponse, + data: Buffer, + contentType: string, + source: string + ): void { // CORS headers if (this.settings.allowCrossDomain) { res.setHeader('Access-Control-Allow-Origin', '*'); diff --git a/backend/src/proxy/server.ts b/backend/src/proxy/server.ts index 47d3a39..256d83b 100644 --- a/backend/src/proxy/server.ts +++ b/backend/src/proxy/server.ts @@ -23,7 +23,7 @@ export class GameProxyServer { this.app = express(); this.config = { ...proxyConfig, - chunkSize: proxyConfig.chunkSize || 8192 // 8KB default chunk size + chunkSize: proxyConfig.chunkSize || 8192, // 8KB default chunk size }; this.setupMiddleware(); this.setupRoutes(); @@ -35,7 +35,10 @@ export class GameProxyServer { this.app.use((req, res, next) => { res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); - res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept'); + res.header( + 'Access-Control-Allow-Headers', + 'Origin, X-Requested-With, Content-Type, Accept' + ); if (req.method === 'OPTIONS') { res.sendStatus(200); @@ -68,8 +71,8 @@ export class GameProxyServer { res.status(500).json({ error: { message: 'Proxy server error', - statusCode: 500 - } + statusCode: 500, + }, }); }); } @@ -113,8 +116,8 @@ export class GameProxyServer { error: { message: 'File not found', path: requestPath, - statusCode: 404 - } + statusCode: 404, + }, }); } @@ -159,7 +162,7 @@ export class GameProxyServer { const pathsToTry = [ path.join(this.config.legacyHTDOCSPath, requestPath), - path.join(this.config.gameDataPath, requestPath) + path.join(this.config.gameDataPath, requestPath), ]; for (const filePath of pathsToTry) { @@ -201,7 +204,9 @@ export class GameProxyServer { const result = await zipManager.findFile(requestPath); if (result) { - logger.info(`[Proxy] ✓ Found in ZIP ${result.mountId}: ${requestPath} (${result.data.length} bytes)`); + logger.info( + `[Proxy] ✓ Found in ZIP ${result.mountId}: ${requestPath} (${result.data.length} bytes)` + ); return result.data; } @@ -231,7 +236,7 @@ export class GameProxyServer { // Find all .zip files in Data/Games const files = fs.readdirSync(gamesPath); - const zipFiles = files.filter(f => f.toLowerCase().endsWith('.zip')); + const zipFiles = files.filter((f) => f.toLowerCase().endsWith('.zip')); if (zipFiles.length === 0) { logger.info(`[Proxy] No ZIP files found in ${gamesPath}`); @@ -274,7 +279,11 @@ export class GameProxyServer { /** * Serve content from ZIP file */ - private async serveZipContent(content: Buffer, requestPath: string, res: Response): Promise { + private async serveZipContent( + content: Buffer, + requestPath: string, + res: Response + ): Promise { try { // Determine MIME type from file extension const ext = path.extname(requestPath).substring(1).toLowerCase(); @@ -287,7 +296,9 @@ export class GameProxyServer { res.send(content); - logger.debug(`[Proxy] Served from ZIP: ${requestPath} (${mimeType}, ${content.length} bytes)`); + logger.debug( + `[Proxy] Served from ZIP: ${requestPath} (${mimeType}, ${content.length} bytes)` + ); } catch (error) { logger.error('[Proxy] Error serving ZIP content:', error); throw error; @@ -307,7 +318,7 @@ export class GameProxyServer { // Encode each path segment individually // This prevents double-encoding while ensuring special characters are handled const pathSegments = urlObj.pathname.split('/'); - const encodedSegments = pathSegments.map(segment => { + const encodedSegments = pathSegments.map((segment) => { if (!segment) return segment; // Preserve empty segments (leading/trailing slashes) try { @@ -333,7 +344,9 @@ export class GameProxyServer { return urlObj.toString(); } catch (error) { // If URL parsing fails, return original URL - logger.warn(`[Proxy] Failed to encode URL for CDN: ${error instanceof Error ? error.message : 'Unknown error'}`); + logger.warn( + `[Proxy] Failed to encode URL for CDN: ${error instanceof Error ? error.message : 'Unknown error'}` + ); return url; } } @@ -354,8 +367,8 @@ export class GameProxyServer { maxRedirects: 5, headers: { // Match PHP router's Accept-Encoding header - 'Accept-Encoding': 'identity' - } + 'Accept-Encoding': 'identity', + }, // Note: Removed validateStatus to allow axios to follow redirects automatically }); @@ -368,7 +381,9 @@ export class GameProxyServer { continue; } - logger.info(`[Proxy] ✓ Successfully fetched from external: ${encodedUrl} (${buffer.length} bytes)`); + logger.info( + `[Proxy] ✓ Successfully fetched from external: ${encodedUrl} (${buffer.length} bytes)` + ); // Cache the downloaded file locally for future use await this.cacheDownloadedFile(requestPath, buffer); @@ -426,7 +441,7 @@ export class GameProxyServer { // Stream file with chunking for large files const readStream = fs.createReadStream(filePath, { - highWaterMark: this.config.chunkSize + highWaterMark: this.config.chunkSize, }); readStream.on('error', (error) => { @@ -435,8 +450,8 @@ export class GameProxyServer { res.status(500).json({ error: { message: 'Error streaming file', - statusCode: 500 - } + statusCode: 500, + }, }); } }); @@ -477,8 +492,13 @@ export class GameProxyServer { server.on('error', (error: NodeJS.ErrnoException) => { if (error.code === 'EADDRINUSE') { logger.error(`[Proxy] Port ${this.config.proxyPort} is already in use.`); - logger.error('[Proxy] Please check if another instance is running or change PROXY_PORT in .env'); - logger.error('[Proxy] You can find the process using: netstat -ano | findstr :' + this.config.proxyPort); + logger.error( + '[Proxy] Please check if another instance is running or change PROXY_PORT in .env' + ); + logger.error( + '[Proxy] You can find the process using: netstat -ano | findstr :' + + this.config.proxyPort + ); } else { logger.error('[Proxy] Failed to start proxy server:', error); } diff --git a/backend/src/proxy/zip-manager.ts b/backend/src/proxy/zip-manager.ts index f43d0bd..0ca6044 100644 --- a/backend/src/proxy/zip-manager.ts +++ b/backend/src/proxy/zip-manager.ts @@ -110,9 +110,9 @@ export class ZipManager { async findFile(relPath: string): Promise<{ data: Buffer; mountId: string } | null> { // Try with different prefixes (different ZIP structures) const pathsToTry = [ - `content/${relPath}`, // Most common: content/domain/path - `htdocs/${relPath}`, // Standard: htdocs/domain/path - relPath, // No prefix: domain/path + `content/${relPath}`, // Most common: content/domain/path + `htdocs/${relPath}`, // Standard: htdocs/domain/path + relPath, // No prefix: domain/path `Legacy/htdocs/${relPath}`, // Full path: Legacy/htdocs/domain/path ]; @@ -176,7 +176,7 @@ export class ZipManager { if (pattern) { const regex = new RegExp(pattern, 'i'); - files = files.filter(f => regex.test(f)); + files = files.filter((f) => regex.test(f)); } return files; @@ -199,7 +199,7 @@ export class ZipManager { async unmountAll(): Promise { logger.info(`[ZipManager] Unmounting all ZIPs (${this.mountedZips.size})...`); - const promises = Array.from(this.mountedZips.keys()).map(id => this.unmount(id)); + const promises = Array.from(this.mountedZips.keys()).map((id) => this.unmount(id)); await Promise.all(promises); logger.info(`[ZipManager] ✓ All ZIPs unmounted`); diff --git a/backend/src/routes/activities.ts b/backend/src/routes/activities.ts index d6f6794..7ba85f4 100644 --- a/backend/src/routes/activities.ts +++ b/backend/src/routes/activities.ts @@ -31,7 +31,7 @@ const activityQuerySchema = z.object({ startDate: z.string().optional(), endDate: z.string().optional(), sortBy: z.enum(['createdAt', 'username', 'action', 'resource', 'ipAddress']).default('createdAt'), - sortOrder: z.enum(['asc', 'desc']).default('desc') + sortOrder: z.enum(['asc', 'desc']).default('desc'), }); // Apply feature flag check to all routes in this router @@ -54,11 +54,22 @@ router.get( return res.status(400).json({ success: false, error: 'Invalid query parameters', - details: queryResult.error.errors + details: queryResult.error.errors, }); } - const { page, limit, userId, username, action, resource, startDate, endDate, sortBy, sortOrder } = queryResult.data; + const { + page, + limit, + userId, + username, + action, + resource, + startDate, + endDate, + sortBy, + sortOrder, + } = queryResult.data; // Build filters object const filters: any = {}; @@ -85,12 +96,13 @@ router.get( requirePermission('activities.read'), asyncHandler(async (req, res) => { const timeRange = (req.query.timeRange as '24h' | '7d' | '30d') || DEFAULT_TIME_RANGE; - const customRange = req.query.startDate && req.query.endDate - ? { - startDate: req.query.startDate as string, - endDate: req.query.endDate as string - } - : undefined; + const customRange = + req.query.startDate && req.query.endDate + ? { + startDate: req.query.startDate as string, + endDate: req.query.endDate as string, + } + : undefined; const stats = await activityService.getStats(timeRange, customRange); @@ -99,8 +111,8 @@ router.get( data: stats, meta: { calculatedAt: new Date().toISOString(), - timeRange - } + timeRange, + }, }); }) ); @@ -120,7 +132,7 @@ router.get( res.json({ success: true, - ...result + ...result, }); }) ); @@ -141,7 +153,7 @@ router.get( res.json({ success: true, - ...result + ...result, }); }) ); @@ -163,7 +175,7 @@ router.get( if (!['resource', 'user', 'ip'].includes(groupBy)) { return res.status(400).json({ success: false, - error: 'Invalid groupBy parameter. Must be one of: resource, user, ip' + error: 'Invalid groupBy parameter. Must be one of: resource, user, ip', }); } @@ -171,7 +183,7 @@ router.get( res.json({ success: true, - ...result + ...result, }); }) ); diff --git a/backend/src/routes/auth-settings.ts b/backend/src/routes/auth-settings.ts index ea3df04..b769936 100644 --- a/backend/src/routes/auth-settings.ts +++ b/backend/src/routes/auth-settings.ts @@ -17,17 +17,20 @@ const updateSettingsSchema = z.object({ requireEmailVerification: z.boolean().optional(), sessionTimeoutMinutes: z.number().int().positive().optional(), maxLoginAttempts: z.number().int().positive().optional(), - lockoutDurationMinutes: z.number().int().positive().optional() + lockoutDurationMinutes: z.number().int().positive().optional(), }); /** * GET /api/settings/auth * Get current auth settings (public endpoint) */ -router.get('/', asyncHandler(async (req, res) => { - const settings = authSettingsService.getSettings(); - res.json(settings); -})); +router.get( + '/', + asyncHandler(async (req, res) => { + const settings = authSettingsService.getSettings(); + res.json(settings); + }) +); /** * PATCH /api/settings/auth diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index e2b4f3e..2a86eeb 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -21,8 +21,8 @@ const loginLimiter = rateLimit({ message: { error: { message: 'Too many login attempts from this IP, please try again after 15 minutes', - statusCode: 429 - } + statusCode: 429, + }, }, standardHeaders: true, legacyHeaders: false, @@ -32,10 +32,10 @@ const loginLimiter = rateLimit({ res.status(429).json({ error: { message: 'Too many login attempts from this IP, please try again after 15 minutes', - statusCode: 429 - } + statusCode: 429, + }, }); - } + }, }); // Moderate limits on registration @@ -45,8 +45,8 @@ const registerLimiter = rateLimit({ message: { error: { message: 'Too many registration attempts from this IP, please try again after an hour', - statusCode: 429 - } + statusCode: 429, + }, }, standardHeaders: true, legacyHeaders: false, @@ -55,26 +55,26 @@ const registerLimiter = rateLimit({ res.status(429).json({ error: { message: 'Too many registration attempts from this IP, please try again after an hour', - statusCode: 429 - } + statusCode: 429, + }, }); - } + }, }); // Validation schemas const loginSchema = z.object({ username: z.string().min(3).max(50), - password: z.string().min(6) + password: z.string().min(6), }); const registerSchema = z.object({ username: z.string().min(3).max(50), email: z.string().email(), - password: z.string().min(6) + password: z.string().min(6), }); const refreshTokenSchema = z.object({ - refreshToken: z.string() + refreshToken: z.string(), }); /** @@ -88,7 +88,7 @@ router.get('/setup-status', (req, res) => { needsSetup, message: needsSetup ? 'No users found. Please create an administrator account.' - : 'System is configured' + : 'System is configured', }); }); @@ -96,66 +96,71 @@ router.get('/setup-status', (req, res) => { * POST /api/auth/login * Login with username and password */ -router.post('/login', loginLimiter, asyncHandler(async (req, res) => { - try { - const credentials = loginSchema.parse(req.body); - const ipAddress = req.ip || ''; - - logger.info(`[Auth] Login attempt for user: ${credentials.username} from ${ipAddress}`); - - const result = await authService.login(credentials, ipAddress); - - // Log activity - await activityService.log({ - userId: result.user.id, - username: result.user.username, - action: 'login', - resource: 'auth', - ipAddress, - userAgent: req.headers['user-agent'] - }); - - logger.info(`[Auth] Login successful for user: ${credentials.username}`); - res.json(result); - } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[Auth] Validation error during login: ${error.errors[0].message}`); - throw new AppError(400, `Validation error: ${error.errors[0].message}`); - } - - // Log failed login attempt - const ipAddress = req.ip || ''; - const username = req.body.username || 'unknown'; - +router.post( + '/login', + loginLimiter, + asyncHandler(async (req, res) => { try { + const credentials = loginSchema.parse(req.body); + const ipAddress = req.ip || ''; + + logger.info(`[Auth] Login attempt for user: ${credentials.username} from ${ipAddress}`); + + const result = await authService.login(credentials, ipAddress); + + // Log activity await activityService.log({ - userId: undefined, - username, - action: 'auth.login.failed', + userId: result.user.id, + username: result.user.username, + action: 'login', resource: 'auth', - details: { - reason: error instanceof Error && error.message.includes('Invalid') - ? 'invalid_credentials' - : 'other', - errorMessage: error instanceof Error ? error.message : 'Unknown error' - }, ipAddress, - userAgent: req.headers['user-agent'] + userAgent: req.headers['user-agent'], }); - } catch (logError) { - // Don't fail request if logging fails - logger.error('[Auth] Failed to log failed login attempt:', logError); + + logger.info(`[Auth] Login successful for user: ${credentials.username}`); + res.json(result); + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn(`[Auth] Validation error during login: ${error.errors[0].message}`); + throw new AppError(400, `Validation error: ${error.errors[0].message}`); + } + + // Log failed login attempt + const ipAddress = req.ip || ''; + const username = req.body.username || 'unknown'; + + try { + await activityService.log({ + userId: undefined, + username, + action: 'auth.login.failed', + resource: 'auth', + details: { + reason: + error instanceof Error && error.message.includes('Invalid') + ? 'invalid_credentials' + : 'other', + errorMessage: error instanceof Error ? error.message : 'Unknown error', + }, + ipAddress, + userAgent: req.headers['user-agent'], + }); + } catch (logError) { + // Don't fail request if logging fails + logger.error('[Auth] Failed to log failed login attempt:', logError); + } + + // Log the error with full details before passing to error handler + logger.error(`[Auth] Login failed for user: ${username}`, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + + throw error; } - - // Log the error with full details before passing to error handler - logger.error(`[Auth] Login failed for user: ${username}`, { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined - }); - - throw error; - } -})); + }) +); /** * POST /api/auth/register @@ -167,40 +172,44 @@ router.post('/login', loginLimiter, asyncHandler(async (req, res) => { * * The AuthService.register() method automatically detects which scenario applies */ -router.post('/register', registerLimiter, asyncHandler(async (req, res) => { - try { - const data = registerSchema.parse(req.body); +router.post( + '/register', + registerLimiter, + asyncHandler(async (req, res) => { + try { + const data = registerSchema.parse(req.body); - logger.info(`[Auth] Registration attempt for user: ${data.username}`); + logger.info(`[Auth] Registration attempt for user: ${data.username}`); - const result = await authService.register(data); + const result = await authService.register(data); - // Log activity - await activityService.log({ - userId: result.user.id, - username: result.user.username, - action: 'register', - resource: 'auth', - ipAddress: req.ip, - userAgent: req.headers['user-agent'] - }); + // Log activity + await activityService.log({ + userId: result.user.id, + username: result.user.username, + action: 'register', + resource: 'auth', + ipAddress: req.ip, + userAgent: req.headers['user-agent'], + }); - logger.info(`[Auth] Registration successful for user: ${data.username}`); - res.status(201).json(result); - } catch (error) { - if (error instanceof z.ZodError) { - logger.warn(`[Auth] Validation error during registration: ${error.errors[0].message}`); - throw new AppError(400, `Validation error: ${error.errors[0].message}`); + logger.info(`[Auth] Registration successful for user: ${data.username}`); + res.status(201).json(result); + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn(`[Auth] Validation error during registration: ${error.errors[0].message}`); + throw new AppError(400, `Validation error: ${error.errors[0].message}`); + } + + logger.error(`[Auth] Registration failed for user: ${req.body.username}`, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + + throw error; } - - logger.error(`[Auth] Registration failed for user: ${req.body.username}`, { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined - }); - - throw error; - } -})); + }) +); /** * POST /api/auth/logout @@ -222,28 +231,34 @@ router.post( * POST /api/auth/refresh * Refresh access token using refresh token */ -router.post('/refresh', asyncHandler(async (req, res) => { - const { refreshToken } = refreshTokenSchema.parse(req.body); +router.post( + '/refresh', + asyncHandler(async (req, res) => { + const { refreshToken } = refreshTokenSchema.parse(req.body); - const tokens = await authService.refreshToken(refreshToken); + const tokens = await authService.refreshToken(refreshToken); - res.json(tokens); -})); + res.json(tokens); + }) +); /** * GET /api/auth/me * Get current authenticated user */ -router.get('/me', asyncHandler(async (req, res) => { - const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith('Bearer ')) { - throw new AppError(401, 'No token provided'); - } +router.get( + '/me', + asyncHandler(async (req, res) => { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new AppError(401, 'No token provided'); + } - const token = authHeader.substring(7); - const user = await authService.verifyAccessToken(token); + const token = authHeader.substring(7); + const user = await authService.verifyAccessToken(token); - res.json(user); -})); + res.json(user); + }) +); export default router; diff --git a/backend/src/routes/cache.ts b/backend/src/routes/cache.ts index bcaa16c..2cf8910 100644 --- a/backend/src/routes/cache.ts +++ b/backend/src/routes/cache.ts @@ -24,13 +24,13 @@ router.get( size: gameSearchStats.primary.size, max: gameSearchStats.primary.max, ttl: gameSearchStats.primary.ttl, - hitRate: gameSearchStats.primary.size > 0 ? 'active' : 'idle' + hitRate: gameSearchStats.primary.size > 0 ? 'active' : 'idle', }, permissions: { enabled: true, ttl: '5 minutes', - note: 'Permission cache size not exposed' - } + note: 'Permission cache size not exposed', + }, }); }) ); @@ -68,7 +68,7 @@ router.post( res.json({ message: 'Cache cleared successfully', - cleared: results + cleared: results, }); }) ); diff --git a/backend/src/routes/community-playlists.ts b/backend/src/routes/community-playlists.ts index 751d4d6..991f897 100644 --- a/backend/src/routes/community-playlists.ts +++ b/backend/src/routes/community-playlists.ts @@ -13,7 +13,7 @@ router.get( '/', optionalAuth, logActivity('playlists.community.browse', 'community_playlists', (req, res) => ({ - playlistCount: res.locals.playlistCount || 0 + playlistCount: res.locals.playlistCount || 0, })), asyncHandler(async (req, res) => { const playlists = await communityPlaylistService.fetchCommunityPlaylists(); @@ -36,7 +36,7 @@ router.post( downloadUrl: req.body.downloadUrl, playlistTitle: res.locals.playlistTitle || null, playlistId: res.locals.playlistId || null, - gameCount: res.locals.gameCount || 0 + gameCount: res.locals.gameCount || 0, })), asyncHandler(async (req, res) => { const { downloadUrl } = req.body; diff --git a/backend/src/routes/database.ts b/backend/src/routes/database.ts index 7872c3e..53db96c 100644 --- a/backend/src/routes/database.ts +++ b/backend/src/routes/database.ts @@ -29,22 +29,22 @@ router.post( requirePermission('settings.update'), logActivity('database.reload', 'database'), async (req: Request, res: Response) => { - try { - logger.info('Manual database reload requested'); + try { + logger.info('Manual database reload requested'); - await DatabaseService.reload(); + await DatabaseService.reload(); - res.json({ - success: true, - message: 'Database reloaded successfully from disk' - }); - } catch (error) { - logger.error('Manual database reload failed:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Unknown error' - }); - } + res.json({ + success: true, + message: 'Database reloaded successfully from disk', + }); + } catch (error) { + logger.error('Manual database reload failed:', error); + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } } ); @@ -69,28 +69,28 @@ router.get( requirePermission('settings.update'), logActivity('database.status', 'database'), async (req: Request, res: Response) => { - try { - const fs = require('fs'); - const { config } = require('../config'); + try { + const fs = require('fs'); + const { config } = require('../config'); - const isConnected = DatabaseService.isConnected(); - const stats = fs.statSync(config.flashpointDbPath); + const isConnected = DatabaseService.isConnected(); + const stats = fs.statSync(config.flashpointDbPath); - res.json({ - success: true, - connected: isConnected, - dbPath: config.flashpointDbPath, - fileSizeBytes: stats.size, - fileSizeMB: (stats.size / (1024 * 1024)).toFixed(2), - lastModified: stats.mtime.toISOString() - }); - } catch (error) { - logger.error('Error getting database status:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Unknown error' - }); - } + res.json({ + success: true, + connected: isConnected, + dbPath: config.flashpointDbPath, + fileSizeBytes: stats.size, + fileSizeMB: (stats.size / (1024 * 1024)).toFixed(2), + lastModified: stats.mtime.toISOString(), + }); + } catch (error) { + logger.error('Error getting database status:', error); + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } } ); diff --git a/backend/src/routes/domains.ts b/backend/src/routes/domains.ts index d758a7e..3af6a05 100644 --- a/backend/src/routes/domains.ts +++ b/backend/src/routes/domains.ts @@ -57,7 +57,13 @@ router.post( const domain = domainService.addDomain(validation.data.hostname, req.user!.id); res.status(201).json(domain); } catch (error) { - if (error instanceof Error && (error.message.includes('already exists') || error.message.includes('must not') || error.message.includes('Invalid') || error.message.includes('cannot be empty'))) { + if ( + error instanceof Error && + (error.message.includes('already exists') || + error.message.includes('must not') || + error.message.includes('Invalid') || + error.message.includes('cannot be empty')) + ) { return res.status(400).json({ error: { message: error.message }, }); diff --git a/backend/src/routes/downloads.ts b/backend/src/routes/downloads.ts index 9bb477f..fb42f32 100644 --- a/backend/src/routes/downloads.ts +++ b/backend/src/routes/downloads.ts @@ -38,131 +38,132 @@ router.post( gameId: req.params.id, gameDataId: req.body.gameDataId || res.locals.gameDataId, gameTitle: res.locals.gameTitle, - sources: res.locals.sources + sources: res.locals.sources, })), async (req: Request, res: Response) => { - try { - const gameId = req.params.id; - const { gameDataId: requestedGameDataId } = req.body; + try { + const gameId = req.params.id; + const { gameDataId: requestedGameDataId } = req.body; - logger.info('Download request received', { gameId, requestedGameDataId }); + logger.info('Download request received', { gameId, requestedGameDataId }); - // Get game from database - const game = await gameService.getGameById(gameId); - if (!game) { - return res.status(404).json({ - success: false, - error: 'Game not found' - }); - } - - // Determine which game data to download - let gameDataId = requestedGameDataId; - if (!gameDataId) { - // Query game_data table to find the game data entry for this game - const foundGameDataId = await gameService.getGameDataId(gameId); - if (!foundGameDataId) { + // Get game from database + const game = await gameService.getGameById(gameId); + if (!game) { return res.status(404).json({ success: false, - error: 'Game has no game data to download' + error: 'Game not found', }); } - gameDataId = foundGameDataId; - } - // Check if already downloaded - const isDownloaded = await GameDatabaseUpdater.isDownloaded(gameDataId); - if (isDownloaded) { - return res.status(409).json({ - success: false, - error: 'Game data already downloaded' - }); - } + // Determine which game data to download + let gameDataId = requestedGameDataId; + if (!gameDataId) { + // Query game_data table to find the game data entry for this game + const foundGameDataId = await gameService.getGameDataId(gameId); + if (!foundGameDataId) { + return res.status(404).json({ + success: false, + error: 'Game has no game data to download', + }); + } + gameDataId = foundGameDataId; + } - // Get game data info - const gameData = await GameDatabaseUpdater.getGameData(gameDataId); - if (!gameData) { - return res.status(404).json({ - success: false, - error: 'Game data not found' - }); - } + // Check if already downloaded + const isDownloaded = await GameDatabaseUpdater.isDownloaded(gameDataId); + if (isDownloaded) { + return res.status(409).json({ + success: false, + error: 'Game data already downloaded', + }); + } - // Get data sources from preferences - const sources = await PreferencesService.getGameDataSources(); - if (sources.length === 0) { - return res.status(500).json({ - success: false, - error: 'No game data sources configured. Please configure gameDataSources in preferences.json' - }); - } + // Get game data info + const gameData = await GameDatabaseUpdater.getGameData(gameDataId); + if (!gameData) { + return res.status(404).json({ + success: false, + error: 'Game data not found', + }); + } - // Store metadata for activity logging - res.locals.gameDataId = gameDataId; - res.locals.gameTitle = game.title; - res.locals.sources = sources.map(s => s.name); + // Get data sources from preferences + const sources = await PreferencesService.getGameDataSources(); + if (sources.length === 0) { + return res.status(500).json({ + success: false, + error: + 'No game data sources configured. Please configure gameDataSources in preferences.json', + }); + } - // Capture metadata for background error logging - const downloadMetadata = { - gameId, - gameDataId, - gameTitle: game.title, - sha256: gameData.sha256, - sources: sources.map(s => s.name), - userId: req.user?.id, - username: req.user?.username, - ipAddress: req.ip - }; + // Store metadata for activity logging + res.locals.gameDataId = gameDataId; + res.locals.gameTitle = game.title; + res.locals.sources = sources.map((s) => s.name); - // Start download (non-blocking) - // We don't wait for it to complete - client uses SSE endpoint for progress - DownloadManager.downloadGameData( - gameDataId, - sources, - undefined, // Progress callback handled by SSE endpoint - undefined, // Details callback handled by SSE endpoint - undefined // No abort signal for now - ).catch(async error => { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - logger.error('Background download failed', { + // Capture metadata for background error logging + const downloadMetadata = { gameId, gameDataId, - error: errorMessage + gameTitle: game.title, + sha256: gameData.sha256, + sources: sources.map((s) => s.name), + userId: req.user?.id, + username: req.user?.username, + ipAddress: req.ip, + }; + + // Start download (non-blocking) + // We don't wait for it to complete - client uses SSE endpoint for progress + DownloadManager.downloadGameData( + gameDataId, + sources, + undefined, // Progress callback handled by SSE endpoint + undefined, // Details callback handled by SSE endpoint + undefined // No abort signal for now + ).catch(async (error) => { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Background download failed', { + gameId, + gameDataId, + error: errorMessage, + }); + + // Log download failure activity + await activityService.log({ + userId: downloadMetadata.userId, + username: downloadMetadata.username, + action: 'games.download.failed', + resource: 'games', + resourceId: downloadMetadata.gameId, + details: { + gameDataId: downloadMetadata.gameDataId, + gameTitle: downloadMetadata.gameTitle, + sha256: downloadMetadata.sha256, + sources: downloadMetadata.sources, + error: errorMessage, + }, + ipAddress: downloadMetadata.ipAddress, + userAgent: req.get('user-agent'), + }); }); - // Log download failure activity - await activityService.log({ - userId: downloadMetadata.userId, - username: downloadMetadata.username, - action: 'games.download.failed', - resource: 'games', - resourceId: downloadMetadata.gameId, - details: { - gameDataId: downloadMetadata.gameDataId, - gameTitle: downloadMetadata.gameTitle, - sha256: downloadMetadata.sha256, - sources: downloadMetadata.sources, - error: errorMessage - }, - ipAddress: downloadMetadata.ipAddress, - userAgent: req.get('user-agent') + // Return success immediately + res.json({ + success: true, + message: 'Download started', + gameDataId, + sha256: gameData.sha256, }); - }); - - // Return success immediately - res.json({ - success: true, - message: 'Download started', - gameDataId, - sha256: gameData.sha256 - }); - } catch (error) { - logger.error('Download endpoint error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Unknown error' - }); - } + } catch (error) { + logger.error('Download endpoint error:', error); + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } } ); @@ -188,118 +189,130 @@ router.get( authenticate, requirePermission('games.download'), async (req: Request, res: Response) => { - try { - const gameId = req.params.id; + try { + const gameId = req.params.id; - // Set SSE headers - res.setHeader('Content-Type', 'text/event-stream'); - res.setHeader('Cache-Control', 'no-cache'); - res.setHeader('Connection', 'keep-alive'); - res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering + // Set SSE headers + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering - logger.info('SSE connection established', { gameId }); + logger.info('SSE connection established', { gameId }); - // Get game to find gameDataId - const game = await gameService.getGameById(gameId); - if (!game) { - res.write(`data: ${JSON.stringify({ - status: 'error', - error: 'Game not found' - })}\n\n`); - res.end(); - return; - } + // Get game to find gameDataId + const game = await gameService.getGameById(gameId); + if (!game) { + res.write( + `data: ${JSON.stringify({ + status: 'error', + error: 'Game not found', + })}\n\n` + ); + res.end(); + return; + } - const gameDataId = await gameService.getGameDataId(gameId); - if (!gameDataId) { - res.write(`data: ${JSON.stringify({ - status: 'error', - error: 'Game has no game data' - })}\n\n`); - res.end(); - return; - } + const gameDataId = await gameService.getGameDataId(gameId); + if (!gameDataId) { + res.write( + `data: ${JSON.stringify({ + status: 'error', + error: 'Game has no game data', + })}\n\n` + ); + res.end(); + return; + } - // Send initial status - res.write(`data: ${JSON.stringify({ - percent: 0, - status: 'waiting', - details: 'Preparing download...' - })}\n\n`); + // Send initial status + res.write( + `data: ${JSON.stringify({ + percent: 0, + status: 'waiting', + details: 'Preparing download...', + })}\n\n` + ); - // Poll for download progress - // Note: This is a simplified implementation. In production, you'd want - // a proper event emitter system to push updates rather than polling. - const pollInterval = setInterval(async () => { - try { - // Check if download is still active - const isActive = DownloadManager.isDownloadActive(gameDataId); + // Poll for download progress + // Note: This is a simplified implementation. In production, you'd want + // a proper event emitter system to push updates rather than polling. + const pollInterval = setInterval(async () => { + try { + // Check if download is still active + const isActive = DownloadManager.isDownloadActive(gameDataId); - if (!isActive) { - // Check if download completed successfully - const isDownloaded = await GameDatabaseUpdater.isDownloaded(gameDataId); + if (!isActive) { + // Check if download completed successfully + const isDownloaded = await GameDatabaseUpdater.isDownloaded(gameDataId); - if (isDownloaded) { - res.write(`data: ${JSON.stringify({ - percent: 100, - status: 'complete', - details: 'Download completed successfully' - })}\n\n`); + if (isDownloaded) { + res.write( + `data: ${JSON.stringify({ + percent: 100, + status: 'complete', + details: 'Download completed successfully', + })}\n\n` + ); - // Log download completion activity - const gameData = await GameDatabaseUpdater.getGameData(gameDataId); - await activityService.log({ - userId: req.user?.id, - username: req.user?.username, - action: 'games.download.complete', - resource: 'games', - resourceId: gameId, - details: { - gameDataId, - gameTitle: game.title, - sha256: gameData?.sha256 - }, - ipAddress: req.ip, - userAgent: req.get('user-agent') - }); + // Log download completion activity + const gameData = await GameDatabaseUpdater.getGameData(gameDataId); + await activityService.log({ + userId: req.user?.id, + username: req.user?.username, + action: 'games.download.complete', + resource: 'games', + resourceId: gameId, + details: { + gameDataId, + gameTitle: game.title, + sha256: gameData?.sha256, + }, + ipAddress: req.ip, + userAgent: req.get('user-agent'), + }); + } else { + res.write( + `data: ${JSON.stringify({ + percent: 0, + status: 'error', + details: 'Download not active', + })}\n\n` + ); + } + + clearInterval(pollInterval); + res.end(); } else { - res.write(`data: ${JSON.stringify({ - percent: 0, - status: 'error', - details: 'Download not active' - })}\n\n`); + // Send heartbeat + res.write( + `data: ${JSON.stringify({ + percent: 0, + status: 'downloading', + details: 'Download in progress...', + })}\n\n` + ); } - + } catch (error) { + logger.error('Error in SSE poll', { error }); clearInterval(pollInterval); res.end(); - } else { - // Send heartbeat - res.write(`data: ${JSON.stringify({ - percent: 0, - status: 'downloading', - details: 'Download in progress...' - })}\n\n`); } - } catch (error) { - logger.error('Error in SSE poll', { error }); + }, 1000); // Poll every second + + // Clean up on client disconnect + req.on('close', () => { + logger.info('SSE connection closed', { gameId }); clearInterval(pollInterval); res.end(); - } - }, 1000); // Poll every second - - // Clean up on client disconnect - req.on('close', () => { - logger.info('SSE connection closed', { gameId }); - clearInterval(pollInterval); - res.end(); - }); - } catch (error) { - logger.error('SSE endpoint error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Unknown error' - }); - } + }); + } catch (error) { + logger.error('SSE endpoint error:', error); + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } } ); @@ -322,50 +335,50 @@ router.delete( requirePermission('games.download'), logActivity('games.download_cancel', 'games'), async (req: Request, res: Response) => { - try { - const gameId = req.params.id; + try { + const gameId = req.params.id; - logger.info('Download cancel request', { gameId }); + logger.info('Download cancel request', { gameId }); - // Get game to find gameDataId - const game = await gameService.getGameById(gameId); - if (!game) { - return res.status(404).json({ + // Get game to find gameDataId + const game = await gameService.getGameById(gameId); + if (!game) { + return res.status(404).json({ + success: false, + error: 'Game not found', + }); + } + + const gameDataId = await gameService.getGameDataId(gameId); + if (!gameDataId) { + return res.status(404).json({ + success: false, + error: 'Game has no game data', + }); + } + + // Attempt to cancel download + const cancelled = DownloadManager.cancelDownload(gameDataId); + + if (!cancelled) { + return res.status(404).json({ + success: false, + error: 'No active download found for this game', + }); + } + + res.json({ + success: true, + cancelled: true, + message: 'Download cancelled successfully', + }); + } catch (error) { + logger.error('Cancel download error:', error); + res.status(500).json({ success: false, - error: 'Game not found' + error: error instanceof Error ? error.message : 'Unknown error', }); } - - const gameDataId = await gameService.getGameDataId(gameId); - if (!gameDataId) { - return res.status(404).json({ - success: false, - error: 'Game has no game data' - }); - } - - // Attempt to cancel download - const cancelled = DownloadManager.cancelDownload(gameDataId); - - if (!cancelled) { - return res.status(404).json({ - success: false, - error: 'No active download found for this game' - }); - } - - res.json({ - success: true, - cancelled: true, - message: 'Download cancelled successfully' - }); - } catch (error) { - logger.error('Cancel download error:', error); - res.status(500).json({ - success: false, - error: error instanceof Error ? error.message : 'Unknown error' - }); - } } ); diff --git a/backend/src/routes/errors.ts b/backend/src/routes/errors.ts index db7cad6..bd7d5b6 100644 --- a/backend/src/routes/errors.ts +++ b/backend/src/routes/errors.ts @@ -24,10 +24,10 @@ if (!fsSync.existsSync(LOG_DIR)) { */ function getSeverityNumber(type: string): number { const severityMap: Record = { - client_error: 17, // ERROR - api_error: 17, // ERROR - network_error: 13, // WARN (may be transient) - route_error: 13, // WARN + client_error: 17, // ERROR + api_error: 17, // ERROR + network_error: 13, // WARN (may be transient) + route_error: 13, // WARN }; return severityMap[type] || 17; } @@ -55,9 +55,9 @@ const clientErrorLogger = winston.createLogger({ filename: ERROR_LOG_FILE, maxsize: 10485760, // 10MB maxFiles: 5, - tailable: true - }) - ] + tailable: true, + }), + ], }); logger.info(`[ClientErrors] Log file: ${ERROR_LOG_FILE}`); @@ -98,7 +98,7 @@ async function logError(report: ErrorReport): Promise { userAgent: report.userAgent, userId: report.userId, stack: report.stack, - context: report.context + context: report.context, }); } catch (error) { logger.error('Failed to log client error:', error); @@ -132,7 +132,7 @@ async function getRecentErrors(limit: number = 100): Promise { timestamp: logEntry.clientTimestamp || logEntry.timestamp, userAgent: logEntry.userAgent, userId: logEntry.userId, - context: logEntry.context + context: logEntry.context, }); } catch (parseError) { logger.warn('Failed to parse error log line:', parseError); @@ -143,7 +143,12 @@ async function getRecentErrors(limit: number = 100): Promise { return errors; } catch (readError) { // File doesn't exist yet - handle ENOENT error - if (readError && typeof readError === 'object' && 'code' in readError && readError.code === 'ENOENT') { + if ( + readError && + typeof readError === 'object' && + 'code' in readError && + readError.code === 'ENOENT' + ) { return []; } throw readError; @@ -169,8 +174,8 @@ router.post('/report', async (req: Request, res: Response) => { success: false, error: { code: 'VALIDATION_ERROR', - message: 'Missing required fields: type, message, url' - } + message: 'Missing required fields: type, message, url', + }, }); } @@ -202,8 +207,8 @@ router.post('/report', async (req: Request, res: Response) => { success: false, error: { code: 'INTERNAL_ERROR', - message: 'Failed to log error report' - } + message: 'Failed to log error report', + }, }); } }); @@ -227,8 +232,8 @@ router.get( data: { errors, count: errors.length, - limit - } + limit, + }, }); } catch (error) { logger.error('Error in /errors/recent:', error); @@ -236,8 +241,8 @@ router.get( success: false, error: { code: 'INTERNAL_ERROR', - message: 'Failed to retrieve error reports' - } + message: 'Failed to retrieve error reports', + }, }); } } @@ -263,11 +268,11 @@ router.get( const stats = { total: errors.length, - last24h: errors.filter(e => new Date(e.timestamp) >= last24h).length, - last7d: errors.filter(e => new Date(e.timestamp) >= last7d).length, + last24h: errors.filter((e) => new Date(e.timestamp) >= last24h).length, + last7d: errors.filter((e) => new Date(e.timestamp) >= last7d).length, byType: {} as Record, topUrls: {} as Record, - topMessages: {} as Record + topMessages: {} as Record, }; // Group by type @@ -293,7 +298,7 @@ router.get( res.json({ success: true, - data: stats + data: stats, }); } catch (error) { logger.error('Error in /errors/stats:', error); @@ -301,8 +306,8 @@ router.get( success: false, error: { code: 'INTERNAL_ERROR', - message: 'Failed to retrieve error statistics' - } + message: 'Failed to retrieve error statistics', + }, }); } } diff --git a/backend/src/routes/favorites.ts b/backend/src/routes/favorites.ts index 2b5fdbf..f59baa1 100644 --- a/backend/src/routes/favorites.ts +++ b/backend/src/routes/favorites.ts @@ -20,15 +20,15 @@ router.use(requireFeature('enableFavorites')); // Validation schemas const toggleFavoriteSchema = z.object({ - gameId: z.string() + gameId: z.string(), }); const batchAddSchema = z.object({ - gameIds: z.array(z.string()).min(1) + gameIds: z.array(z.string()).min(1), }); const batchRemoveSchema = z.object({ - gameIds: z.array(z.string()).min(1) + gameIds: z.array(z.string()).min(1), }); /** @@ -88,7 +88,13 @@ router.get( const sortBy = (req.query.sortBy as 'title' | 'dateAdded') || 'dateAdded'; const sortOrder = (req.query.sortOrder as 'asc' | 'desc') || 'desc'; - const games = await favoritesService.getUserFavoriteGames(req.user.id, limit, offset, sortBy, sortOrder); + const games = await favoritesService.getUserFavoriteGames( + req.user.id, + limit, + offset, + sortBy, + sortOrder + ); res.json(games); }) ); diff --git a/backend/src/routes/game-files.ts b/backend/src/routes/game-files.ts index 8c4e8d5..0a9eea9 100644 --- a/backend/src/routes/game-files.ts +++ b/backend/src/routes/game-files.ts @@ -14,39 +14,42 @@ const gameFileService = new GameFileService(); * - /game-files/www.example.com/path/file.swf * - /game-files/cdn.games.com/assets/game.swf */ -router.get('/*', asyncHandler(async (req: Request, res: Response) => { - try { - // Extract path after /game-files/ - const urlPath = req.path.substring(1); // Remove leading slash +router.get( + '/*', + asyncHandler(async (req: Request, res: Response) => { + try { + // Extract path after /game-files/ + const urlPath = req.path.substring(1); // Remove leading slash - if (!urlPath) { - throw new AppError(400, 'File path is required'); + if (!urlPath) { + throw new AppError(400, 'File path is required'); + } + + logger.info(`[GameFilesRoute] Request: ${urlPath}`); + + // Get the file (tries Game Server first, then external sources) + const result = await gameFileService.getGameFile(urlPath); + + logger.info(`[GameFilesRoute] Serving from ${result.source}: ${result.data.length} bytes`); + + // Set headers + res.setHeader('Content-Type', result.contentType); + res.setHeader('Content-Length', result.data.length); + res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('X-Source', result.source); // Debug header + + // Send file + res.send(result.data); + } catch (error) { + if (error instanceof Error && error.message.includes('not found')) { + throw new AppError(404, 'Game file not found in any source'); + } else { + logger.error('[GameFilesRoute] Error:', error); + throw error; + } } - - logger.info(`[GameFilesRoute] Request: ${urlPath}`); - - // Get the file (tries Game Server first, then external sources) - const result = await gameFileService.getGameFile(urlPath); - - logger.info(`[GameFilesRoute] Serving from ${result.source}: ${result.data.length} bytes`); - - // Set headers - res.setHeader('Content-Type', result.contentType); - res.setHeader('Content-Length', result.data.length); - res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('X-Source', result.source); // Debug header - - // Send file - res.send(result.data); - } catch (error) { - if (error instanceof Error && error.message.includes('not found')) { - throw new AppError(404, 'Game file not found in any source'); - } else { - logger.error('[GameFilesRoute] Error:', error); - throw error; - } - } -})); + }) +); export default router; diff --git a/backend/src/routes/games.ts b/backend/src/routes/games.ts index eb537b5..2d79044 100644 --- a/backend/src/routes/games.ts +++ b/backend/src/routes/games.ts @@ -41,12 +41,14 @@ const searchQuerySchema = z.object({ yearTo: z.coerce.number().int().min(1970).max(2100).optional(), dateAddedSince: z.string().datetime().optional(), dateModifiedSince: z.string().datetime().optional(), - sortBy: z.enum(['title', 'releaseDate', 'dateAdded', 'dateModified', 'developer']).default('title'), + sortBy: z + .enum(['title', 'releaseDate', 'dateAdded', 'dateModified', 'developer']) + .default('title'), sortOrder: z.enum(['asc', 'desc']).default('asc'), page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().min(1).max(100).default(50), showBroken: booleanSchema.default(false), - showExtreme: booleanSchema.default(false) + showExtreme: booleanSchema.default(false), }); // GET /api/games - List games with pagination and filters @@ -54,14 +56,25 @@ router.get( '/', logActivity('games.search', 'games', (req, res) => { // Count active filters - const filters = ['platform', 'series', 'developers', 'publishers', 'playModes', 'languages', 'library', 'tags', 'yearFrom', 'yearTo']; - const activeFilters = filters.filter(f => req.query[f]).length; + const filters = [ + 'platform', + 'series', + 'developers', + 'publishers', + 'playModes', + 'languages', + 'library', + 'tags', + 'yearFrom', + 'yearTo', + ]; + const activeFilters = filters.filter((f) => req.query[f]).length; return { query: req.query.search || null, filterCount: activeFilters, resultCount: res.locals.resultCount || 0, - hasSearch: !!req.query.search + hasSearch: !!req.query.search, }; }), asyncHandler(async (req, res) => { @@ -87,7 +100,7 @@ router.get( page: query.page, limit: query.limit, showBroken: query.showBroken, - showExtreme: query.showExtreme + showExtreme: query.showExtreme, }); // Store result count for activity logging @@ -98,22 +111,28 @@ router.get( ); // GET /api/games/filter-options - Get all filter options for dropdowns -router.get('/filter-options', asyncHandler(async (req, res) => { - const filterOptions = await gameService.getFilterOptions(); - res.json(filterOptions); -})); +router.get( + '/filter-options', + asyncHandler(async (req, res) => { + const filterOptions = await gameService.getFilterOptions(); + res.json(filterOptions); + }) +); // GET /api/games/most-played - Get most played games globally -router.get('/most-played', asyncHandler(async (req, res) => { - const limit = Math.min(parseInt(req.query.limit as string) || 20, 50); - const games = await gameService.getMostPlayedGames(limit); +router.get( + '/most-played', + asyncHandler(async (req, res) => { + const limit = Math.min(parseInt(req.query.limit as string) || 20, 50); + const games = await gameService.getMostPlayedGames(limit); - res.json({ - success: true, - data: games, - total: games.length - }); -})); + res.json({ + success: true, + data: games, + total: games.length, + }); + }) +); // GET /api/games/:id - Get single game by ID router.get( @@ -123,7 +142,7 @@ router.get( platform: res.locals.game?.platformName, library: res.locals.game?.library, broken: res.locals.game?.broken, - extreme: res.locals.game?.extreme + extreme: res.locals.game?.extreme, })), asyncHandler(async (req, res) => { const game = await gameService.getGameById(req.params.id); @@ -140,24 +159,31 @@ router.get( ); // GET /api/games/:id/related - Get related games -router.get('/:id/related', validateSharedGameAccess('id'), asyncHandler(async (req, res) => { - const limit = Math.min(parseInt(req.query.limit as string) || 10, 50); - const relatedGames = await gameService.getRelatedGames(req.params.id, limit); +router.get( + '/:id/related', + validateSharedGameAccess('id'), + asyncHandler(async (req, res) => { + const limit = Math.min(parseInt(req.query.limit as string) || 10, 50); + const relatedGames = await gameService.getRelatedGames(req.params.id, limit); - res.json(relatedGames); -})); + res.json(relatedGames); + }) +); // GET /api/games/random - Get random game -router.get('/random', asyncHandler(async (req, res) => { - const library = req.query.library as string | undefined; - const game = await gameService.getRandomGame(library); +router.get( + '/random', + asyncHandler(async (req, res) => { + const library = req.query.library as string | undefined; + const game = await gameService.getRandomGame(library); - if (!game) { - throw new AppError(404, 'No games found'); - } + if (!game) { + throw new AppError(404, 'No games found'); + } - res.json(game); -})); + res.json(game); + }) +); // GET /api/games/:id/launch - Get game launch data router.get( @@ -166,7 +192,7 @@ router.get( logActivity('games.launch.request', 'games', (req, res) => ({ platform: res.locals.platform, canPlayInBrowser: res.locals.canPlayInBrowser, - launchCommand: res.locals.launchCommand + launchCommand: res.locals.launchCommand, })), asyncHandler(async (req, res) => { const game = await gameService.getGameById(req.params.id); @@ -253,7 +279,7 @@ router.get( contentUrl, applicationPath: game.applicationPath, playMode: game.playMode, - canPlayInBrowser + canPlayInBrowser, }); }) ); diff --git a/backend/src/routes/github.ts b/backend/src/routes/github.ts index 26bfcaa..6293b86 100644 --- a/backend/src/routes/github.ts +++ b/backend/src/routes/github.ts @@ -14,30 +14,30 @@ router.get('/stars', async (_req: Request, res: Response) => { const response = await fetch(repoUrl, { headers: { - 'Accept': 'application/vnd.github.v3+json', - 'User-Agent': 'Flashpoint-Web-App' - } + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'Flashpoint-Web-App', + }, }); if (!response.ok) { throw new Error(`GitHub API error: ${response.status}`); } - const data = await response.json() as { stargazers_count: number }; + const data = (await response.json()) as { stargazers_count: number }; res.json({ success: true, data: { - stars: data.stargazers_count - } + stars: data.stargazers_count, + }, }); } catch (error) { logger.error('Failed to fetch GitHub stars:', error); res.status(500).json({ success: false, error: { - message: 'Failed to fetch GitHub star count' - } + message: 'Failed to fetch GitHub star count', + }, }); } }); diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts index a193d2b..96967e7 100644 --- a/backend/src/routes/health.ts +++ b/backend/src/routes/health.ts @@ -19,7 +19,7 @@ router.get('/', (req, res) => { res.status(isHealthy ? 200 : 503).json({ status: isHealthy ? 'healthy' : 'unhealthy', - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }); }); @@ -43,7 +43,9 @@ router.get( try { if (DatabaseService.isConnected()) { // Test query to verify database is accessible - const result = DatabaseService.get('SELECT COUNT(*) as count FROM game', []) as { count: number } | null; + const result = DatabaseService.get('SELECT COUNT(*) as count FROM game', []) as { + count: number; + } | null; flashpointDbRecordCount = result?.count || 0; flashpointDbStatus = 'connected'; } @@ -60,7 +62,9 @@ router.get( try { if (UserDatabaseService.isConnected()) { // Test query to verify database is accessible - const result = UserDatabaseService.get('SELECT COUNT(*) as count FROM users', []) as { count: number } | null; + const result = UserDatabaseService.get('SELECT COUNT(*) as count FROM users', []) as { + count: number; + } | null; userCount = result?.count || 0; userDbStatus = 'connected'; } @@ -88,7 +92,7 @@ router.get( rss: formatBytes(memoryUsage.rss), heapUsed: formatBytes(memoryUsage.heapUsed), heapTotal: formatBytes(memoryUsage.heapTotal), - external: formatBytes(memoryUsage.external) + external: formatBytes(memoryUsage.external), }; const healthCheckTime = Math.round(performance.now() - startTime); @@ -101,20 +105,20 @@ router.get( timestamp: new Date().toISOString(), uptime: { seconds: uptimeSeconds, - formatted: uptimeFormatted + formatted: uptimeFormatted, }, databases: { flashpoint: { status: flashpointDbStatus, path: config.flashpointDbPath, gameCount: flashpointDbRecordCount, - error: flashpointDbError + error: flashpointDbError, }, user: { status: userDbStatus, userCount, - error: userDbError - } + error: userDbError, + }, }, caches: { permissions: permissionCacheStats, @@ -122,23 +126,23 @@ router.get( size: gameSearchCacheStats.primary.size, max: gameSearchCacheStats.primary.max, ttl: '5 minutes', - hitRate: gameSearchCacheStats.primary.size > 0 ? 'active' : 'idle' - } + hitRate: gameSearchCacheStats.primary.size > 0 ? 'active' : 'idle', + }, }, memory: memoryFormatted, environment: { nodeEnv: config.nodeEnv, nodeVersion: process.version, platform: process.platform, - arch: process.arch + arch: process.arch, }, services: { gameService: { proxyUrl: config.gameServerUrl || 'http://localhost:22500', - gameZipPort: config.gameServerHttpPort || 22501 - } + gameZipPort: config.gameServerHttpPort || 22501, + }, }, - healthCheckDuration: `${healthCheckTime}ms` + healthCheckDuration: `${healthCheckTime}ms`, }); }) ); diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 7aac2ca..e63a8b7 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -95,8 +95,8 @@ export function setupRoutes(app: Express): void { res.status(404).json({ error: { message: 'Route not found', - statusCode: 404 - } + statusCode: 404, + }, }); }); } diff --git a/backend/src/routes/jobs.ts b/backend/src/routes/jobs.ts index 6d7b7d5..ac2cb60 100644 --- a/backend/src/routes/jobs.ts +++ b/backend/src/routes/jobs.ts @@ -69,7 +69,7 @@ router.patch( // Validate request body if (typeof enabled !== 'boolean') { return res.status(400).json({ - error: { message: 'Invalid request: enabled must be a boolean' } + error: { message: 'Invalid request: enabled must be a boolean' }, }); } @@ -100,7 +100,7 @@ router.patch( } catch (error) { logger.error(`Failed to update job ${req.params.jobId}:`, error); res.status(500).json({ - error: { message: error instanceof Error ? error.message : 'Failed to update job' } + error: { message: error instanceof Error ? error.message : 'Failed to update job' }, }); } } @@ -123,7 +123,7 @@ router.post( logger.error(`Failed to start job ${req.params.jobId}:`, error); res.status(500).json({ success: false, - message: error instanceof Error ? error.message : 'Failed to start job' + message: error instanceof Error ? error.message : 'Failed to start job', }); } } @@ -146,7 +146,7 @@ router.post( logger.error(`Failed to stop job ${req.params.jobId}:`, error); res.status(500).json({ success: false, - message: error instanceof Error ? error.message : 'Failed to stop job' + message: error instanceof Error ? error.message : 'Failed to stop job', }); } } @@ -168,7 +168,7 @@ router.post( await JobScheduler.triggerJob(jobId, userId); res.json({ success: true, - message: 'Job triggered successfully' + message: 'Job triggered successfully', }); } catch (error) { const message = error instanceof Error ? error.message : 'Failed to trigger job'; diff --git a/backend/src/routes/metrics.ts b/backend/src/routes/metrics.ts index 9f2d4c1..98ea846 100644 --- a/backend/src/routes/metrics.ts +++ b/backend/src/routes/metrics.ts @@ -20,7 +20,7 @@ router.get( res.json({ success: true, - data: summary + data: summary, }); }) ); @@ -39,7 +39,7 @@ router.get( res.json({ success: true, - data: stats + data: stats, }); }) ); @@ -59,7 +59,7 @@ router.get( res.json({ success: true, - data: slowest + data: slowest, }); }) ); @@ -78,7 +78,7 @@ router.get( res.json({ success: true, - data: cacheStats + data: cacheStats, }); }) ); @@ -97,7 +97,7 @@ router.get( res.json({ success: true, - data: queryStats + data: queryStats, }); }) ); @@ -116,7 +116,7 @@ router.post( res.json({ success: true, - message: 'Performance metrics reset successfully' + message: 'Performance metrics reset successfully', }); }) ); diff --git a/backend/src/routes/platforms.ts b/backend/src/routes/platforms.ts index 40bb0c9..fa8c7ee 100644 --- a/backend/src/routes/platforms.ts +++ b/backend/src/routes/platforms.ts @@ -13,8 +13,10 @@ router.use(rateLimitStandard); router.use(optionalAuth); // GET /api/platforms - List all platforms -router.get('/', asyncHandler(async (req, res) => { - const sql = ` +router.get( + '/', + asyncHandler(async (req, res) => { + const sql = ` SELECT DISTINCT platformName as platform, COUNT(*) as count FROM game WHERE platformName IS NOT NULL AND platformName != '' @@ -22,9 +24,10 @@ router.get('/', asyncHandler(async (req, res) => { ORDER BY platformName ASC `; - const platforms = DatabaseService.all(sql, []) as Array<{ platform: string; count: number }>; + const platforms = DatabaseService.all(sql, []) as Array<{ platform: string; count: number }>; - res.json(platforms); -})); + res.json(platforms); + }) +); export default router; diff --git a/backend/src/routes/play-tracking.ts b/backend/src/routes/play-tracking.ts index e6a945a..88ac3ee 100644 --- a/backend/src/routes/play-tracking.ts +++ b/backend/src/routes/play-tracking.ts @@ -26,7 +26,7 @@ router.post( logActivity('play.start', 'games', (req, res) => ({ sessionId: res.locals.sessionId, gameTitle: req.body.gameTitle, - gameId: req.body.gameId + gameId: req.body.gameId, })), asyncHandler(async (req: Request, res: Response) => { const { gameId, gameTitle } = req.body; @@ -39,18 +39,14 @@ router.post( throw new AppError(401, 'Authentication required'); } - const sessionId = await playTrackingService.startPlaySession( - req.user.id, - gameId, - gameTitle - ); + const sessionId = await playTrackingService.startPlaySession(req.user.id, gameId, gameTitle); // Store sessionId for activity logging res.locals.sessionId = sessionId; res.json({ success: true, - sessionId + sessionId, }); }) ); @@ -73,7 +69,7 @@ router.post( res.json({ success: true, - message: 'Play session ended successfully' + message: 'Play session ended successfully', }); }) ); @@ -82,104 +78,122 @@ router.post( * GET /api/play/stats * Get current user's overall stats */ -router.get('/stats', asyncHandler(async (req: Request, res: Response) => { - if (!req.user) { - throw new AppError(401, 'Authentication required'); - } +router.get( + '/stats', + asyncHandler(async (req: Request, res: Response) => { + if (!req.user) { + throw new AppError(401, 'Authentication required'); + } - const stats = await playTrackingService.getUserStats(req.user.id); + const stats = await playTrackingService.getUserStats(req.user.id); - res.json(stats); -})); + res.json(stats); + }) +); /** * GET /api/play/game-stats * Get current user's game-specific stats */ -router.get('/game-stats', asyncHandler(async (req: Request, res: Response) => { - if (!req.user) { - throw new AppError(401, 'Authentication required'); - } +router.get( + '/game-stats', + asyncHandler(async (req: Request, res: Response) => { + if (!req.user) { + throw new AppError(401, 'Authentication required'); + } - const limit = Math.min(parseInt(req.query.limit as string) || 50, 100); - const offset = parseInt(req.query.offset as string) || 0; + const limit = Math.min(parseInt(req.query.limit as string) || 50, 100); + const offset = parseInt(req.query.offset as string) || 0; - const stats = await playTrackingService.getUserGameStats(req.user.id, limit, offset); + const stats = await playTrackingService.getUserGameStats(req.user.id, limit, offset); - res.json({ - data: stats, - limit, - offset - }); -})); + res.json({ + data: stats, + limit, + offset, + }); + }) +); /** * GET /api/play/history * Get current user's play history */ -router.get('/history', asyncHandler(async (req: Request, res: Response) => { - if (!req.user) { - throw new AppError(401, 'Authentication required'); - } +router.get( + '/history', + asyncHandler(async (req: Request, res: Response) => { + if (!req.user) { + throw new AppError(401, 'Authentication required'); + } - const limit = Math.min(parseInt(req.query.limit as string) || 50, 100); - const offset = parseInt(req.query.offset as string) || 0; + const limit = Math.min(parseInt(req.query.limit as string) || 50, 100); + const offset = parseInt(req.query.offset as string) || 0; - const history = await playTrackingService.getUserPlayHistory(req.user.id, limit, offset); + const history = await playTrackingService.getUserPlayHistory(req.user.id, limit, offset); - res.json({ - data: history, - limit, - offset - }); -})); + res.json({ + data: history, + limit, + offset, + }); + }) +); /** * GET /api/play/top-games * Get current user's top played games */ -router.get('/top-games', asyncHandler(async (req: Request, res: Response) => { - if (!req.user) { - throw new AppError(401, 'Authentication required'); - } +router.get( + '/top-games', + asyncHandler(async (req: Request, res: Response) => { + if (!req.user) { + throw new AppError(401, 'Authentication required'); + } - const limit = Math.min(parseInt(req.query.limit as string) || 10, 50); + const limit = Math.min(parseInt(req.query.limit as string) || 10, 50); - const topGames = await playTrackingService.getTopGames(req.user.id, limit); + const topGames = await playTrackingService.getTopGames(req.user.id, limit); - res.json(topGames); -})); + res.json(topGames); + }) +); /** * GET /api/play/activity-over-time * Get play activity over time (daily aggregation) */ -router.get('/activity-over-time', asyncHandler(async (req: Request, res: Response) => { - if (!req.user) { - throw new AppError(401, 'Authentication required'); - } +router.get( + '/activity-over-time', + asyncHandler(async (req: Request, res: Response) => { + if (!req.user) { + throw new AppError(401, 'Authentication required'); + } - const days = Math.min(parseInt(req.query.days as string) || 30, 365); + const days = Math.min(parseInt(req.query.days as string) || 30, 365); - const activity = await playTrackingService.getPlayActivityOverTime(req.user.id, days); + const activity = await playTrackingService.getPlayActivityOverTime(req.user.id, days); - res.json(activity); -})); + res.json(activity); + }) +); /** * GET /api/play/games-distribution * Get games distribution by playtime */ -router.get('/games-distribution', asyncHandler(async (req: Request, res: Response) => { - if (!req.user) { - throw new AppError(401, 'Authentication required'); - } +router.get( + '/games-distribution', + asyncHandler(async (req: Request, res: Response) => { + if (!req.user) { + throw new AppError(401, 'Authentication required'); + } - const limit = Math.min(parseInt(req.query.limit as string) || 10, 20); + const limit = Math.min(parseInt(req.query.limit as string) || 10, 20); - const distribution = await playTrackingService.getGamesDistribution(req.user.id, limit); + const distribution = await playTrackingService.getGamesDistribution(req.user.id, limit); - res.json(distribution); -})); + res.json(distribution); + }) +); export default router; diff --git a/backend/src/routes/playlists.ts b/backend/src/routes/playlists.ts index 79ee81c..e603f6f 100644 --- a/backend/src/routes/playlists.ts +++ b/backend/src/routes/playlists.ts @@ -1,5 +1,9 @@ import { Router } from 'express'; -import { PlaylistService, CreatePlaylistDto, AddGamesToPlaylistDto } from '../services/PlaylistService'; +import { + PlaylistService, + CreatePlaylistDto, + AddGamesToPlaylistDto, +} from '../services/PlaylistService'; import { AppError } from '../middleware/errorHandler'; import { asyncHandler } from '../middleware/asyncHandler'; import { authenticate } from '../middleware/auth'; @@ -19,22 +23,28 @@ router.use(requireFeature('enablePlaylists')); // GET /api/playlists - List all playlists // No additional auth needed - softAuth already runs at router level -router.get('/', asyncHandler(async (req, res) => { - const playlists = await playlistService.getAllPlaylists(); - res.json(playlists); -})); +router.get( + '/', + asyncHandler(async (req, res) => { + const playlists = await playlistService.getAllPlaylists(); + res.json(playlists); + }) +); // GET /api/playlists/:id - Get playlist by ID with games // No additional auth needed - softAuth already runs at router level -router.get('/:id', asyncHandler(async (req, res) => { - const playlist = await playlistService.getPlaylistById(req.params.id); +router.get( + '/:id', + asyncHandler(async (req, res) => { + const playlist = await playlistService.getPlaylistById(req.params.id); - if (!playlist) { - throw new AppError(404, 'Playlist not found'); - } + if (!playlist) { + throw new AppError(404, 'Playlist not found'); + } - res.json(playlist); -})); + res.json(playlist); + }) +); // POST /api/playlists - Create new playlist router.post( diff --git a/backend/src/routes/proxy.ts b/backend/src/routes/proxy.ts index 758c539..1528ce8 100644 --- a/backend/src/routes/proxy.ts +++ b/backend/src/routes/proxy.ts @@ -69,7 +69,7 @@ async function serveFileWithFallback( const response = await axios.get(externalUrl, { responseType: 'arraybuffer', timeout: 10000, // 10 second timeout - validateStatus: (status) => status === 200 + validateStatus: (status) => status === 200, }); if (response.status === 200 && response.data) { @@ -89,7 +89,9 @@ async function serveFileWithFallback( logger.info(`[Proxy] Cached image locally: ${localPath}`); } catch (cacheError) { // Log cache errors but don't fail the request - logger.warn(`[Proxy] Failed to cache image locally: ${cacheError instanceof Error ? cacheError.message : 'Unknown error'}`); + logger.warn( + `[Proxy] Failed to cache image locally: ${cacheError instanceof Error ? cacheError.message : 'Unknown error'}` + ); } })(); @@ -101,7 +103,9 @@ async function serveFileWithFallback( return res.send(imageBuffer); } } catch (err) { - logger.debug(`[Proxy] Failed to fetch from ${baseUrl}: ${err instanceof Error ? err.message : 'Unknown error'}`); + logger.debug( + `[Proxy] Failed to fetch from ${baseUrl}: ${err instanceof Error ? err.message : 'Unknown error'}` + ); // Continue to next URL } } @@ -111,8 +115,8 @@ async function serveFileWithFallback( res.status(404).json({ error: { message: 'Image not found', - statusCode: 404 - } + statusCode: 404, + }, }); } catch (error) { next(error); @@ -120,114 +124,108 @@ async function serveFileWithFallback( } // Proxy to Flashpoint Game Server for game content -router.use('/game', createProxyMiddleware({ - target: config.gameServerUrl, - changeOrigin: true, - pathRewrite: { - '^/proxy/game': '' // Remove /proxy/game prefix - }, - onProxyReq: (proxyReq, req, _res) => { - // Add headers if needed - if (req.ip) { - proxyReq.setHeader('X-Forwarded-For', req.ip); - } - }, - onError: (err, req, res) => { - logger.error('Proxy error:', err); - res.status(500).json({ - error: { - message: 'Failed to proxy request to game server', - statusCode: 500 +router.use( + '/game', + createProxyMiddleware({ + target: config.gameServerUrl, + changeOrigin: true, + pathRewrite: { + '^/proxy/game': '', // Remove /proxy/game prefix + }, + onProxyReq: (proxyReq, req, _res) => { + // Add headers if needed + if (req.ip) { + proxyReq.setHeader('X-Forwarded-For', req.ip); } - }); - } -})); + }, + onError: (err, req, res) => { + logger.error('Proxy error:', err); + res.status(500).json({ + error: { + message: 'Failed to proxy request to game server', + statusCode: 500, + }, + }); + }, + }) +); // Serve images with CDN fallback -router.get('/images/:path(*)', asyncHandler(async (req, res, next) => { - const relativePath = req.params.path; +router.get( + '/images/:path(*)', + asyncHandler(async (req, res, next) => { + const relativePath = req.params.path; - // Validate path to prevent directory traversal - const localPath = validatePath(relativePath, config.flashpointImagesPath); + // Validate path to prevent directory traversal + const localPath = validatePath(relativePath, config.flashpointImagesPath); - if (!localPath) { - return res.status(403).json({ - error: { - message: 'Access denied', - statusCode: 403 - } - }); - } + if (!localPath) { + return res.status(403).json({ + error: { + message: 'Access denied', + statusCode: 403, + }, + }); + } - // Get external image URLs dynamically from preferences - const externalImageUrls = await getExternalImageUrls(); + // Get external image URLs dynamically from preferences + const externalImageUrls = await getExternalImageUrls(); - await serveFileWithFallback( - localPath, - relativePath, - externalImageUrls, - res, - next - ); -})); + await serveFileWithFallback(localPath, relativePath, externalImageUrls, res, next); + }) +); // Serve logos with CDN fallback -router.get('/logos/:path(*)', asyncHandler(async (req, res, next) => { - const relativePath = req.params.path; +router.get( + '/logos/:path(*)', + asyncHandler(async (req, res, next) => { + const relativePath = req.params.path; - // Validate path to prevent directory traversal - const localPath = validatePath(relativePath, config.flashpointLogosPath); + // Validate path to prevent directory traversal + const localPath = validatePath(relativePath, config.flashpointLogosPath); - if (!localPath) { - return res.status(403).json({ - error: { - message: 'Access denied', - statusCode: 403 - } - }); - } + if (!localPath) { + return res.status(403).json({ + error: { + message: 'Access denied', + statusCode: 403, + }, + }); + } - // Get external image URLs dynamically from preferences - const externalImageUrls = await getExternalImageUrls(); + // Get external image URLs dynamically from preferences + const externalImageUrls = await getExternalImageUrls(); - // For logos, the external path should include 'Logos' subdirectory - const externalUrls = externalImageUrls.map(url => `${url}/../Logos`); + // For logos, the external path should include 'Logos' subdirectory + const externalUrls = externalImageUrls.map((url) => `${url}/../Logos`); - await serveFileWithFallback( - localPath, - relativePath, - externalUrls, - res, - next - ); -})); + await serveFileWithFallback(localPath, relativePath, externalUrls, res, next); + }) +); // Serve screenshots with CDN fallback -router.get('/screenshots/:path(*)', asyncHandler(async (req, res, next) => { - const relativePath = `Screenshots/${req.params.path}`; +router.get( + '/screenshots/:path(*)', + asyncHandler(async (req, res, next) => { + const relativePath = `Screenshots/${req.params.path}`; - // Validate path to prevent directory traversal - const localPath = validatePath(relativePath, config.flashpointImagesPath); + // Validate path to prevent directory traversal + const localPath = validatePath(relativePath, config.flashpointImagesPath); - if (!localPath) { - return res.status(403).json({ - error: { - message: 'Access denied', - statusCode: 403 - } - }); - } + if (!localPath) { + return res.status(403).json({ + error: { + message: 'Access denied', + statusCode: 403, + }, + }); + } - // Get external image URLs dynamically from preferences - const externalImageUrls = await getExternalImageUrls(); + // Get external image URLs dynamically from preferences + const externalImageUrls = await getExternalImageUrls(); - await serveFileWithFallback( - localPath, - relativePath, - externalImageUrls, - res, - next - ); -})); + await serveFileWithFallback(localPath, relativePath, externalImageUrls, res, next); + }) +); export default router; diff --git a/backend/src/routes/roles.ts b/backend/src/routes/roles.ts index 5e62b8e..bc58d31 100644 --- a/backend/src/routes/roles.ts +++ b/backend/src/routes/roles.ts @@ -15,17 +15,17 @@ const createRoleSchema = z.object({ name: z.string().min(3).max(50), description: z.string().optional(), priority: z.number().int().default(0), - permissionIds: z.array(z.number().int().positive()).optional() + permissionIds: z.array(z.number().int().positive()).optional(), }); const updateRoleSchema = z.object({ name: z.string().min(3).max(50).optional(), description: z.string().optional(), - priority: z.number().int().optional() + priority: z.number().int().optional(), }); const updatePermissionsSchema = z.object({ - permissionIds: z.array(z.number().int().positive()) + permissionIds: z.array(z.number().int().positive()), }); /** @@ -114,12 +114,7 @@ router.patch( const id = parseInt(req.params.id); const data = updateRoleSchema.parse(req.body); - const role = await roleService.updateRole( - id, - data.name, - data.description, - data.priority - ); + const role = await roleService.updateRole(id, data.name, data.description, data.priority); res.json(role); }) diff --git a/backend/src/routes/ruffle.ts b/backend/src/routes/ruffle.ts index bb3021c..b55e5ca 100644 --- a/backend/src/routes/ruffle.ts +++ b/backend/src/routes/ruffle.ts @@ -18,7 +18,7 @@ router.get('/version', (req, res, next) => { res.json({ currentVersion, - isInstalled + isInstalled, }); } catch (error) { next(error); @@ -29,18 +29,28 @@ router.get('/version', (req, res, next) => { * GET /api/ruffle/check-update * Check for Ruffle updates (admin only) */ -router.get('/check-update', authenticate, requirePermission('settings.update'), asyncHandler(async (req, res) => { - const updateInfo = await ruffleService.checkForUpdate(); - res.json(updateInfo); -})); +router.get( + '/check-update', + authenticate, + requirePermission('settings.update'), + asyncHandler(async (req, res) => { + const updateInfo = await ruffleService.checkForUpdate(); + res.json(updateInfo); + }) +); /** * POST /api/ruffle/update * Update Ruffle to latest version (admin only) */ -router.post('/update', authenticate, requirePermission('settings.update'), asyncHandler(async (req, res) => { - const result = await ruffleService.updateRuffle(); - res.json(result); -})); +router.post( + '/update', + authenticate, + requirePermission('settings.update'), + asyncHandler(async (req, res) => { + const result = await ruffleService.updateRuffle(); + res.json(result); + }) +); export default router; diff --git a/backend/src/routes/shared-playlists.ts b/backend/src/routes/shared-playlists.ts index 126eba7..9996e73 100644 --- a/backend/src/routes/shared-playlists.ts +++ b/backend/src/routes/shared-playlists.ts @@ -62,11 +62,11 @@ router.get( rateLimitStrict, validateSharedPlaylist, asyncHandler(async (req, res) => { - const games = await playlistService.getSharedPlaylistGames( - req.params.shareToken - ); + const games = await playlistService.getSharedPlaylistGames(req.params.shareToken); - logger.debug(`Shared playlist ${req.sharedPlaylist!.id} games accessed (${games.length} games)`); + logger.debug( + `Shared playlist ${req.sharedPlaylist!.id} games accessed (${games.length} games)` + ); res.json(games); }) @@ -119,7 +119,7 @@ router.post( const accessToken = generateSharedAccessToken({ shareToken: req.params.shareToken, - playlistId: playlist.id + playlistId: playlist.id, }); logger.debug(`Generated shared access token for playlist ${playlist.id}`); @@ -127,7 +127,7 @@ router.post( res.json({ accessToken, expiresIn: 3600, // 60 minutes in seconds - playlistId: playlist.id + playlistId: playlist.id, }); }) ); @@ -156,7 +156,9 @@ router.post( throw new AppError(400, 'Unable to clone playlist'); } - logger.info(`User ${req.user!.id} cloned shared playlist ${req.sharedPlaylist!.id} as "${clonedPlaylist.title}"`); + logger.info( + `User ${req.user!.id} cloned shared playlist ${req.sharedPlaylist!.id} as "${clonedPlaylist.title}"` + ); res.status(201).json(clonedPlaylist); }) diff --git a/backend/src/routes/statistics.ts b/backend/src/routes/statistics.ts index bb27054..55387f7 100644 --- a/backend/src/routes/statistics.ts +++ b/backend/src/routes/statistics.ts @@ -17,9 +17,12 @@ router.use(requireFeature('enableStatistics')); * GET /api/statistics * Get overall Flashpoint archive statistics */ -router.get('/', asyncHandler(async (req, res) => { - const statistics = await statisticsService.getStatistics(); - res.json(statistics); -})); +router.get( + '/', + asyncHandler(async (req, res) => { + const statistics = await statisticsService.getStatistics(); + res.json(statistics); + }) +); export default router; diff --git a/backend/src/routes/system-settings.ts b/backend/src/routes/system-settings.ts index 3d49290..cd2f9fc 100644 --- a/backend/src/routes/system-settings.ts +++ b/backend/src/routes/system-settings.ts @@ -19,11 +19,12 @@ function updateJobScheduler(category: string, settings: Record): vo if ('metadataSyncEnabled' in settings || 'metadataSyncSchedule' in settings) { const currentSettings = systemSettings.getCategory('jobs'); const enabled = settings.metadataSyncEnabled ?? currentSettings.metadataSyncEnabled ?? false; - const cronSchedule = settings.metadataSyncSchedule ?? currentSettings.metadataSyncSchedule ?? '0 * * * *'; + const cronSchedule = + settings.metadataSyncSchedule ?? currentSettings.metadataSyncSchedule ?? '0 * * * *'; JobScheduler.updateJob('metadata-sync', { enabled, - cronSchedule + cronSchedule, }); } } @@ -44,7 +45,7 @@ router.get( } catch (error) { logger.error('Failed to get all settings:', error); res.status(500).json({ - error: { message: 'Failed to retrieve settings' } + error: { message: 'Failed to retrieve settings' }, }); } } @@ -85,7 +86,7 @@ router.get('/public', async (req: Request, res: Response) => { } catch (error) { logger.error('Failed to get public settings:', error); res.status(500).json({ - error: { message: 'Failed to retrieve public settings' } + error: { message: 'Failed to retrieve public settings' }, }); } }); @@ -107,7 +108,7 @@ router.get( } catch (error) { logger.error(`Failed to get ${req.params.category} settings:`, error); res.status(500).json({ - error: { message: 'Failed to retrieve settings' } + error: { message: 'Failed to retrieve settings' }, }); } } @@ -132,8 +133,8 @@ router.patch( return res.status(400).json({ error: { message: 'Invalid request body', - details: validation.error.errors - } + details: validation.error.errors, + }, }); } @@ -155,12 +156,12 @@ router.patch( // Check if it's a validation error if (error instanceof Error && error.message.includes('must be')) { return res.status(400).json({ - error: { message: error.message } + error: { message: error.message }, }); } res.status(500).json({ - error: { message: 'Failed to update settings' } + error: { message: 'Failed to update settings' }, }); } } @@ -181,7 +182,7 @@ router.get( if (value === null) { return res.status(404).json({ - error: { message: `Setting '${fullKey}' not found` } + error: { message: `Setting '${fullKey}' not found` }, }); } @@ -189,7 +190,7 @@ router.get( } catch (error) { logger.error(`Failed to get setting ${req.params.category}.${req.params.key}:`, error); res.status(500).json({ - error: { message: 'Failed to retrieve setting' } + error: { message: 'Failed to retrieve setting' }, }); } } @@ -199,7 +200,7 @@ router.get( // UPDATE SINGLE SETTING (Admin Only) // =================================== const updateSettingSchema = z.object({ - value: z.any() + value: z.any(), }); router.patch( @@ -217,8 +218,8 @@ router.patch( return res.status(400).json({ error: { message: 'Invalid request body', - details: validation.error.errors - } + details: validation.error.errors, + }, }); } @@ -237,12 +238,12 @@ router.patch( // Check if it's a validation error if (error instanceof Error && error.message.includes('must be')) { return res.status(400).json({ - error: { message: error.message } + error: { message: error.message }, }); } res.status(500).json({ - error: { message: 'Failed to update setting' } + error: { message: 'Failed to update setting' }, }); } } @@ -262,7 +263,7 @@ router.get( } catch (error) { logger.error('Failed to get cache stats:', error); res.status(500).json({ - error: { message: 'Failed to retrieve cache statistics' } + error: { message: 'Failed to retrieve cache statistics' }, }); } } @@ -290,7 +291,7 @@ router.post( } catch (error) { logger.error('Failed to clear cache:', error); res.status(500).json({ - error: { message: 'Failed to clear cache' } + error: { message: 'Failed to clear cache' }, }); } } @@ -314,14 +315,14 @@ router.get( totalSize: 'Total cached entries', ttl: { userPermissions: '5 minutes', - rolePermissions: '10 minutes' - } - } + rolePermissions: '10 minutes', + }, + }, }); } catch (error) { logger.error('Failed to get permission cache stats:', error); res.status(500).json({ - error: { message: 'Failed to retrieve permission cache statistics' } + error: { message: 'Failed to retrieve permission cache statistics' }, }); } } @@ -357,14 +358,15 @@ router.post( } else { res.status(400).json({ error: { - message: 'Invalid request. Specify type: "user", "role", "users", "roles", or "all". For "user" or "role", provide an id.' - } + message: + 'Invalid request. Specify type: "user", "role", "users", "roles", or "all". For "user" or "role", provide an id.', + }, }); } } catch (error) { logger.error('Failed to clear permission cache:', error); res.status(500).json({ - error: { message: 'Failed to clear permission cache' } + error: { message: 'Failed to clear permission cache' }, }); } } diff --git a/backend/src/routes/tags.ts b/backend/src/routes/tags.ts index 07d7eaf..4c33774 100644 --- a/backend/src/routes/tags.ts +++ b/backend/src/routes/tags.ts @@ -13,27 +13,33 @@ router.use(rateLimitStandard); router.use(optionalAuth); // GET /api/tags - List all unique tags -router.get('/', asyncHandler(async (req, res) => { - // Get all games with tags - const sql = `SELECT DISTINCT tagsStr FROM game WHERE tagsStr IS NOT NULL AND tagsStr != ''`; - const results = DatabaseService.all(sql, []) as Array<{ tagsStr: string }>; +router.get( + '/', + asyncHandler(async (req, res) => { + // Get all games with tags + const sql = `SELECT DISTINCT tagsStr FROM game WHERE tagsStr IS NOT NULL AND tagsStr != ''`; + const results = DatabaseService.all(sql, []) as Array<{ tagsStr: string }>; - // Parse tags (semicolon-separated) and count occurrences - const tagCounts = new Map(); + // Parse tags (semicolon-separated) and count occurrences + const tagCounts = new Map(); - results.forEach(row => { - const tags = row.tagsStr.split(';').map(t => t.trim()).filter(Boolean); - tags.forEach(tag => { - tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1); + results.forEach((row) => { + const tags = row.tagsStr + .split(';') + .map((t) => t.trim()) + .filter(Boolean); + tags.forEach((tag) => { + tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1); + }); }); - }); - // Convert to array and sort by count - const tags = Array.from(tagCounts.entries()) - .map(([name, count]) => ({ name, count })) - .sort((a, b) => b.count - a.count); + // Convert to array and sort by count + const tags = Array.from(tagCounts.entries()) + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count); - res.json(tags); -})); + res.json(tags); + }) +); export default router; diff --git a/backend/src/routes/updates.ts b/backend/src/routes/updates.ts index ff43649..39cfd63 100644 --- a/backend/src/routes/updates.ts +++ b/backend/src/routes/updates.ts @@ -17,11 +17,14 @@ const metadataSyncService = new MetadataSyncService(); * GET /api/updates/check * Check for available updates */ -router.get('/check', asyncHandler(async (req, res) => { - logger.info('[Updates API] Checking for updates...'); - const updateInfo = await updateService.checkForUpdates(); - res.json(updateInfo); -})); +router.get( + '/check', + asyncHandler(async (req, res) => { + logger.info('[Updates API] Checking for updates...'); + const updateInfo = await updateService.checkForUpdates(); + res.json(updateInfo); + }) +); /** * POST /api/updates/install @@ -64,12 +67,15 @@ router.get( * Returns info about game database updates from FPFSS * Includes edition info so frontend knows whether sync is available */ -router.get('/metadata', asyncHandler(async (req, res) => { - const edition = metadataUpdateService.getEdition(); - logger.info(`[Updates API] Checking for metadata updates... (edition: ${edition})`); - const metadataInfo = await metadataUpdateService.getMetadataUpdateInfo(); - res.json({ ...metadataInfo, edition }); -})); +router.get( + '/metadata', + asyncHandler(async (req, res) => { + const edition = metadataUpdateService.getEdition(); + logger.info(`[Updates API] Checking for metadata updates... (edition: ${edition})`); + const metadataInfo = await metadataUpdateService.getMetadataUpdateInfo(); + res.json({ ...metadataInfo, edition }); + }) +); /** * POST /api/updates/metadata/sync @@ -92,23 +98,24 @@ router.post( return res.status(409).json({ success: false, error: 'Sync already in progress', - status: syncStatus.getStatus() + status: syncStatus.getStatus(), }); } - logger.info(`[Updates API] Starting metadata sync in background (requested by ${req.user?.username})...`); + logger.info( + `[Updates API] Starting metadata sync in background (requested by ${req.user?.username})...` + ); // Start sync in background (don't await - let it run async) - metadataSyncService.syncMetadata() - .catch((error) => { - logger.error('[Updates API] Background sync error:', error); - }); + metadataSyncService.syncMetadata().catch((error) => { + logger.error('[Updates API] Background sync error:', error); + }); // Return immediately res.json({ success: true, message: 'Sync started', - status: syncStatus.getStatus() + status: syncStatus.getStatus(), }); }) ); @@ -129,7 +136,7 @@ router.get('/metadata/sync/status', (req, res) => { stage: 'error', progress: 0, message: 'Error getting sync status', - error: error instanceof Error ? error.message : 'Unknown error' + error: error instanceof Error ? error.message : 'Unknown error', }); } }); diff --git a/backend/src/routes/user-playlists.ts b/backend/src/routes/user-playlists.ts index a3dcd10..50b5fc4 100644 --- a/backend/src/routes/user-playlists.ts +++ b/backend/src/routes/user-playlists.ts @@ -26,30 +26,30 @@ router.use(requireFeature('enablePlaylists')); const createPlaylistSchema = z.object({ title: z.string().min(1).max(255), description: z.string().optional(), - icon: z.string().optional() + icon: z.string().optional(), }); const updatePlaylistSchema = z.object({ title: z.string().min(1).max(255).optional(), description: z.string().optional(), - icon: z.string().optional() + icon: z.string().optional(), }); const addGamesSchema = z.object({ - gameIds: z.array(z.string()).min(1) + gameIds: z.array(z.string()).min(1), }); const removeGamesSchema = z.object({ - gameIds: z.array(z.string()).min(1) + gameIds: z.array(z.string()).min(1), }); const reorderGamesSchema = z.object({ - gameIdOrder: z.array(z.string()).min(1) + gameIdOrder: z.array(z.string()).min(1), }); const copyFlashpointPlaylistSchema = z.object({ flashpointPlaylistId: z.string(), - newTitle: z.string().optional() + newTitle: z.string().optional(), }); /** @@ -61,7 +61,7 @@ router.get( authenticate, requirePermission('playlists.read'), logActivity('playlists.view.list', 'user_playlists', (req, res) => ({ - playlistCount: res.locals.playlistCount || 0 + playlistCount: res.locals.playlistCount || 0, })), asyncHandler(async (req, res) => { if (!req.user) { @@ -106,7 +106,7 @@ router.get( logActivity('playlists.view.detail', 'user_playlists', (req, res) => ({ playlistId: req.params.id, playlistTitle: res.locals.playlistTitle || null, - gameCount: res.locals.gameCount || 0 + gameCount: res.locals.gameCount || 0, })), asyncHandler(async (req, res) => { if (!req.user) { @@ -185,7 +185,7 @@ router.patch( requirePermission('playlists.update'), logActivity('playlists.update.metadata', 'user_playlists', (req, _res) => ({ playlistId: req.params.id, - fieldsUpdated: Object.keys(req.body) + fieldsUpdated: Object.keys(req.body), })), asyncHandler(async (req, res) => { if (!req.user) { @@ -248,7 +248,7 @@ router.post( requirePermission('playlists.update'), logActivity('playlists.games.add', 'user_playlist_games', (req) => ({ playlistId: req.params.id, - gamesAdded: req.body.gameIds?.length || 0 + gamesAdded: req.body.gameIds?.length || 0, })), asyncHandler(async (req, res) => { if (!req.user) { @@ -273,7 +273,7 @@ router.post( res.json({ ...updatedPlaylist, - games + games, }); }) ); @@ -289,7 +289,7 @@ router.delete( requirePermission('playlists.update'), logActivity('playlists.games.remove', 'user_playlist_games', (req) => ({ playlistId: req.params.id, - gamesRemoved: req.body.gameIds?.length || 0 + gamesRemoved: req.body.gameIds?.length || 0, })), asyncHandler(async (req, res) => { if (!req.user) { @@ -314,7 +314,7 @@ router.delete( res.json({ ...updatedPlaylist, - games + games, }); }) ); @@ -330,7 +330,7 @@ router.put( requirePermission('playlists.update'), logActivity('playlists.games.reorder', 'user_playlist_games', (req) => ({ playlistId: req.params.id, - gameCount: req.body.gameIdOrder?.length || 0 + gameCount: req.body.gameIdOrder?.length || 0, })), asyncHandler(async (req, res) => { if (!req.user) { @@ -355,7 +355,7 @@ router.put( res.json({ ...updatedPlaylist, - games + games, }); }) ); @@ -412,7 +412,7 @@ router.post( const shareData = playlistService.enableSharing(playlistId, req.user.id, { expiresAt, - showOwner + showOwner, }); if (!shareData) { @@ -504,7 +504,7 @@ router.patch( const shareData = playlistService.updateShareSettings(playlistId, req.user.id, { expiresAt, - showOwner + showOwner, }); if (!shareData) { diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index f17bc2c..6bb083d 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -16,28 +16,28 @@ const createUserSchema = z.object({ email: z.string().email(), password: z.string().min(6), roleId: z.number().int().positive(), - isActive: z.boolean().optional() + isActive: z.boolean().optional(), }); const updateUserSchema = z.object({ email: z.string().email().optional(), roleId: z.number().int().positive().optional(), - isActive: z.boolean().optional() + isActive: z.boolean().optional(), }); const changePasswordSchema = z.object({ currentPassword: z.string().min(6), - newPassword: z.string().min(6) + newPassword: z.string().min(6), }); const updateThemeSchema = z.object({ themeColor: z.string().min(1), - surfaceColor: z.string().min(1).optional() + surfaceColor: z.string().min(1).optional(), }); const updateThemeSettingsSchema = z.object({ mode: z.enum(['light', 'dark', 'system']), - primaryColor: z.string().min(1) + primaryColor: z.string().min(1), }); /** @@ -97,7 +97,7 @@ router.post( email: data.email, password: data.password, roleId: data.roleId, - isActive: data.isActive ?? true + isActive: data.isActive ?? true, }); res.status(201).json(user); @@ -114,7 +114,7 @@ router.patch( requirePermission('users.update'), logActivity('users.update.profile', 'users', (req) => ({ userId: req.params.id, - fieldsUpdated: Object.keys(req.body) + fieldsUpdated: Object.keys(req.body), })), asyncHandler(async (req, res) => { const id = parseInt(req.params.id); @@ -185,7 +185,7 @@ router.get( // Return in old format for compatibility res.json({ themeColor: `${themeSettings.primaryColor}-500`, - surfaceColor: 'slate-700' + surfaceColor: 'slate-700', }); }) ); @@ -212,7 +212,7 @@ router.patch( res.json({ success: true, themeColor: data.themeColor, - surfaceColor: data.surfaceColor || 'slate-700' + surfaceColor: data.surfaceColor || 'slate-700', }); }) ); @@ -245,11 +245,7 @@ router.patch( const userId = req.user!.id; const validated = updateThemeSettingsSchema.parse(req.body); - await userService.updateThemeSettings( - userId, - validated.mode, - validated.primaryColor - ); + await userService.updateThemeSettings(userId, validated.mode, validated.primaryColor); const updated = await userService.getThemeSettings(userId); res.json(updated); diff --git a/backend/src/server.ts b/backend/src/server.ts index c8e6a5e..c8783c5 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -29,36 +29,40 @@ async function startServer() { const app: Express = express(); // Security middleware - app.use(helmet({ - contentSecurityPolicy: false, // Will be configured in frontend via meta tag - crossOriginEmbedderPolicy: false, - frameguard: { - action: 'deny' // Equivalent to frame-ancestors 'none' - } - })); + app.use( + helmet({ + contentSecurityPolicy: false, // Will be configured in frontend via meta tag + crossOriginEmbedderPolicy: false, + frameguard: { + action: 'deny', // Equivalent to frame-ancestors 'none' + }, + }) + ); // CORS - dynamic origin to support configured domains - app.use(cors({ - origin: (origin, callback) => { - // Allow same-origin and server-to-server requests - if (!origin) return callback(null, true); + app.use( + cors({ + origin: (origin, callback) => { + // Allow same-origin and server-to-server requests + if (!origin) return callback(null, true); - // Always allow the configured domain from env - if (origin === config.domain) return callback(null, true); + // Always allow the configured domain from env + if (origin === config.domain) return callback(null, true); - // Check domains from the database - try { - const domainService = new DomainService(); - const allowed = domainService.getAllowedOrigins(); - if (allowed.has(origin)) return callback(null, true); - } catch { - // DB not ready during startup - fall through - } + // Check domains from the database + try { + const domainService = new DomainService(); + const allowed = domainService.getAllowedOrigins(); + if (allowed.has(origin)) return callback(null, true); + } catch { + // DB not ready during startup - fall through + } - callback(new Error('Not allowed by CORS')); - }, - credentials: true - })); + callback(new Error('Not allowed by CORS')); + }, + credentials: true, + }) + ); // Body parsing with size limits (prevent DoS attacks) app.use(express.json({ limit: '1mb' })); @@ -116,7 +120,7 @@ async function startServer() { // Pre-warm game search cache for instant first page load // Run asynchronously to not block server startup - GameSearchCache.prewarmCache().catch(error => { + GameSearchCache.prewarmCache().catch((error) => { logger.warn('Failed to pre-warm game search cache:', error); // Non-fatal: server continues without pre-warmed cache }); @@ -148,9 +152,15 @@ async function startServer() { JobScheduler.registerJob({ id: 'metadata-sync', name: 'Metadata Sync', - enabled: typeof jobSettings.metadataSyncEnabled === 'boolean' ? jobSettings.metadataSyncEnabled : false, - cronSchedule: typeof jobSettings.metadataSyncSchedule === 'string' ? jobSettings.metadataSyncSchedule : '0 * * * *', // Default: hourly - run: () => metadataSyncJob.run() + enabled: + typeof jobSettings.metadataSyncEnabled === 'boolean' + ? jobSettings.metadataSyncEnabled + : false, + cronSchedule: + typeof jobSettings.metadataSyncSchedule === 'string' + ? jobSettings.metadataSyncSchedule + : '0 * * * *', // Default: hourly + run: () => metadataSyncJob.run(), }); // Register Ruffle update job @@ -158,9 +168,15 @@ async function startServer() { JobScheduler.registerJob({ id: 'ruffle-update', name: 'Ruffle Update', - enabled: typeof jobSettings.ruffleUpdateEnabled === 'boolean' ? jobSettings.ruffleUpdateEnabled : false, - cronSchedule: typeof jobSettings.ruffleUpdateSchedule === 'string' ? jobSettings.ruffleUpdateSchedule : '0 0 * * *', // Default: daily at midnight - run: () => ruffleUpdateJob.run() + enabled: + typeof jobSettings.ruffleUpdateEnabled === 'boolean' + ? jobSettings.ruffleUpdateEnabled + : false, + cronSchedule: + typeof jobSettings.ruffleUpdateSchedule === 'string' + ? jobSettings.ruffleUpdateSchedule + : '0 0 * * *', // Default: daily at midnight + run: () => ruffleUpdateJob.run(), }); // Start all enabled jobs @@ -184,7 +200,7 @@ async function startServer() { timestamp: new Date().toISOString(), flashpointPath: config.flashpointPath, databaseConnected: DatabaseService.isConnected(), - gameServiceUrl: config.gameServerUrl + gameServiceUrl: config.gameServerUrl, }); }); @@ -224,14 +240,17 @@ async function startServer() { // Start cleanup scheduler for abandoned play sessions (every 6 hours) const playTrackingService = new PlayTrackingService(); - const cleanupInterval = setInterval(() => { - playTrackingService.cleanupAbandonedSessions().catch(error => { - logger.error('Failed to cleanup abandoned sessions:', error); - }); - }, 6 * 60 * 60 * 1000); // 6 hours + const cleanupInterval = setInterval( + () => { + playTrackingService.cleanupAbandonedSessions().catch((error) => { + logger.error('Failed to cleanup abandoned sessions:', error); + }); + }, + 6 * 60 * 60 * 1000 + ); // 6 hours // Run initial cleanup - playTrackingService.cleanupAbandonedSessions().catch(error => { + playTrackingService.cleanupAbandonedSessions().catch((error) => { logger.error('Failed to cleanup abandoned sessions:', error); }); diff --git a/backend/src/services/ActivityService.ts b/backend/src/services/ActivityService.ts index 131fb30..16e4028 100644 --- a/backend/src/services/ActivityService.ts +++ b/backend/src/services/ActivityService.ts @@ -1,7 +1,12 @@ import { UserDatabaseService } from './UserDatabaseService'; import { ActivityLog, LogActivityData } from '../types/auth'; import { PaginatedResponse, createPaginatedResponse, calculateOffset } from '../utils/pagination'; -import { ActivityBreakdownRow, UserBreakdownRow, ResourceBreakdownRow, IpBreakdownRow } from '../types/database-rows'; +import { + ActivityBreakdownRow, + UserBreakdownRow, + ResourceBreakdownRow, + IpBreakdownRow, +} from '../types/database-rows'; import { TimeRange, getHoursFromTimeRange } from '../utils/timeRangeUtils'; import { AUTH_ACTIONS, categorizeAction } from '../utils/activityUtils'; @@ -58,9 +63,7 @@ export class ActivityService { params.push(filters.endDate); } - const sql = conditions.length > 0 - ? 'WHERE ' + conditions.join(' AND ') - : ''; + const sql = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : ''; return { sql, params }; } @@ -83,7 +86,7 @@ export class ActivityService { data.resourceId || null, detailsJson, data.ipAddress || null, - data.userAgent || null + data.userAgent || null, ] ); } @@ -105,7 +108,7 @@ export class ActivityService { username: 'username', action: 'action', resource: 'resource', - ipAddress: 'ip_address' + ipAddress: 'ip_address', }; const dbColumn = columnMap[sortBy] || 'created_at'; const order = sortOrder.toUpperCase(); @@ -121,10 +124,12 @@ export class ActivityService { LIMIT ? OFFSET ? `; - const logs = UserDatabaseService.all(sql, [...whereParams, limit, offset]).map(log => ({ - ...log, - details: log.details ? JSON.parse(log.details as string) : null - })); + const logs = UserDatabaseService.all(sql, [...whereParams, limit, offset]).map( + (log) => ({ + ...log, + details: log.details ? JSON.parse(log.details as string) : null, + }) + ); const countSql = `SELECT COUNT(*) as count FROM activity_logs ${whereClause}`; const total = UserDatabaseService.get<{ count: number }>(countSql, whereParams)?.count || 0; @@ -146,7 +151,10 @@ export class ActivityService { /** * Get aggregate statistics for dashboard */ - async getStats(timeRange: TimeRange = '24h', customRange?: { startDate?: string; endDate?: string }) { + async getStats( + timeRange: TimeRange = '24h', + customRange?: { startDate?: string; endDate?: string } + ) { let startDate: string; let endDate: string = new Date().toISOString(); @@ -195,7 +203,9 @@ export class ActivityService { ) as { hour: number; count: number } | null; // Get previous period stats for trends - const previousPeriodStart = new Date(new Date(startDate).getTime() - (new Date(endDate).getTime() - new Date(startDate).getTime())).toISOString(); + const previousPeriodStart = new Date( + new Date(startDate).getTime() - (new Date(endDate).getTime() - new Date(startDate).getTime()) + ).toISOString(); const previousStats = UserDatabaseService.get( `SELECT COUNT(*) as total, @@ -235,21 +245,21 @@ export class ActivityService { ? { hour: peakHourData.hour, count: peakHourData.count, - formattedRange: formatHourRange(peakHourData.hour) + formattedRange: formatHourRange(peakHourData.hour), } : { hour: 0, count: 0, formattedRange: '12:00 AM - 1:00 AM' }, authEvents: { total: currentStats?.authEvents || 0, successful: currentStats?.authSuccessful || 0, - failed: currentStats?.authFailed || 0 + failed: currentStats?.authFailed || 0, }, failedOperations: currentStats?.failedOperations || 0, systemEvents: currentStats?.systemEvents || 0, trends: { totalChange: calculateTrend(currentStats?.total || 0, previousStats?.total || 0), userChange: calculateTrend(currentStats?.uniqueUsers || 0, previousStats?.uniqueUsers || 0), - authChange: calculateTrend(currentStats?.authEvents || 0, previousStats?.authEvents || 0) - } + authChange: calculateTrend(currentStats?.authEvents || 0, previousStats?.authEvents || 0), + }, }; } @@ -264,7 +274,7 @@ export class ActivityService { if (granularity === 'hour') { groupFormat = "datetime(strftime('%Y-%m-%d %H:00:00', created_at))"; } else { - groupFormat = "date(created_at)"; + groupFormat = 'date(created_at)'; } const trendData = UserDatabaseService.all( @@ -293,8 +303,8 @@ export class ActivityService { granularity, startDate, endDate: new Date().toISOString(), - dataPoints: trendData.length - } + dataPoints: trendData.length, + }, }; } @@ -353,7 +363,7 @@ export class ActivityService { }>; return { - data: topActions.map(action => ({ + data: topActions.map((action) => ({ action: action.action, count: action.count, percentage: totalActivities > 0 ? (action.count / totalActivities) * 100 : 0, @@ -361,13 +371,13 @@ export class ActivityService { topResource: action.topResource, exampleActivity: { username: action.exampleUsername || 'System', - timestamp: action.exampleTimestamp - } + timestamp: action.exampleTimestamp, + }, })), meta: { totalActivities, - uniqueActions: topActions.length - } + uniqueActions: topActions.length, + }, }; } @@ -450,33 +460,41 @@ export class ActivityService { throw new Error(`Invalid groupBy parameter: ${groupBy}`); } - const results = UserDatabaseService.all(sql, groupBy === 'user' || groupBy === 'resource' ? [startDate, startDate, limit] : [startDate, limit]) as ActivityBreakdownRow[]; + const results = UserDatabaseService.all( + sql, + groupBy === 'user' || groupBy === 'resource' + ? [startDate, startDate, limit] + : [startDate, limit] + ) as ActivityBreakdownRow[]; return { data: results.map((row: ActivityBreakdownRow) => ({ key: row.key || 'Unknown', count: row.count, percentage: totalActivities > 0 ? (row.count / totalActivities) * 100 : 0, - metadata: groupBy === 'user' - ? { - userId: (row as UserBreakdownRow).userId, - lastActivity: (row as UserBreakdownRow).lastActivity, - topAction: (row as UserBreakdownRow).topAction - } - : groupBy === 'ip' - ? { - associatedUserCount: (row as IpBreakdownRow).associatedUserCount, - associatedUsers: (row as IpBreakdownRow).associatedUsers ? (row as IpBreakdownRow).associatedUsers.split(',').slice(0, 5) : [], - failedAttempts: (row as IpBreakdownRow).failedAttempts - } - : { - topAction: (row as ResourceBreakdownRow).topAction - } + metadata: + groupBy === 'user' + ? { + userId: (row as UserBreakdownRow).userId, + lastActivity: (row as UserBreakdownRow).lastActivity, + topAction: (row as UserBreakdownRow).topAction, + } + : groupBy === 'ip' + ? { + associatedUserCount: (row as IpBreakdownRow).associatedUserCount, + associatedUsers: (row as IpBreakdownRow).associatedUsers + ? (row as IpBreakdownRow).associatedUsers.split(',').slice(0, 5) + : [], + failedAttempts: (row as IpBreakdownRow).failedAttempts, + } + : { + topAction: (row as ResourceBreakdownRow).topAction, + }, })), meta: { groupBy, - total: totalActivities - } + total: totalActivities, + }, }; } } diff --git a/backend/src/services/AuthService.ts b/backend/src/services/AuthService.ts index 288b0d9..3addea2 100644 --- a/backend/src/services/AuthService.ts +++ b/backend/src/services/AuthService.ts @@ -20,7 +20,10 @@ export class AuthService { /** * Login user with username/password */ - async login(credentials: LoginCredentials, ipAddress: string): Promise<{ user: AuthUser; tokens: AuthTokens }> { + async login( + credentials: LoginCredentials, + ipAddress: string + ): Promise<{ user: AuthUser; tokens: AuthTokens }> { try { const { username, password } = credentials; @@ -52,10 +55,10 @@ export class AuthService { await this.recordLoginAttempt(username, ipAddress, true); // Update last login - UserDatabaseService.run( - "UPDATE users SET last_login_at = ? WHERE id = ?", - [new Date().toISOString(), user.id] - ); + UserDatabaseService.run('UPDATE users SET last_login_at = ? WHERE id = ?', [ + new Date().toISOString(), + user.id, + ]); // Get user permissions const permissions = this.getUserPermissions(user.id); @@ -71,7 +74,7 @@ export class AuthService { role: user.role_name, permissions, themeColor: user.theme_color || 'blue-500', - surfaceColor: user.surface_color || 'slate-700' + surfaceColor: user.surface_color || 'slate-700', }; return { user: authUser, tokens }; @@ -79,7 +82,7 @@ export class AuthService { logger.error('[AuthService] Login error:', { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, - username: credentials.username + username: credentials.username, }); throw error; } @@ -108,19 +111,17 @@ export class AuthService { } // Check if username exists - const existingUser = UserDatabaseService.get( - 'SELECT id FROM users WHERE username = ?', - [data.username] - ); + const existingUser = UserDatabaseService.get('SELECT id FROM users WHERE username = ?', [ + data.username, + ]); if (existingUser) { throw new AppError(409, 'Username already exists'); } // Check if email exists - const existingEmail = UserDatabaseService.get( - 'SELECT id FROM users WHERE email = ?', - [data.email] - ); + const existingEmail = UserDatabaseService.get('SELECT id FROM users WHERE email = ?', [ + data.email, + ]); if (existingEmail) { throw new AppError(409, 'Email already exists'); } @@ -158,7 +159,9 @@ export class AuthService { [userId, defaultTheme, userId, defaultPrimaryColor] ); - logger.info(`[AuthService] Created user ${data.username} with default theme: ${defaultTheme}, primary color: ${defaultPrimaryColor}`); + logger.info( + `[AuthService] Created user ${data.username} with default theme: ${defaultTheme}, primary color: ${defaultPrimaryColor}` + ); // Get created user const user = UserDatabaseService.get( @@ -182,7 +185,7 @@ export class AuthService { role: user.role_name, permissions, themeColor: user.theme_color || 'blue-500', - surfaceColor: user.surface_color || 'slate-700' + surfaceColor: user.surface_color || 'slate-700', }; return { user: authUser, tokens }; @@ -192,10 +195,10 @@ export class AuthService { * Logout user (revoke refresh token) */ async logout(refreshToken: string): Promise { - UserDatabaseService.run( - "UPDATE refresh_tokens SET revoked_at = ? WHERE token = ?", - [new Date().toISOString(), refreshToken] - ); + UserDatabaseService.run('UPDATE refresh_tokens SET revoked_at = ? WHERE token = ?', [ + new Date().toISOString(), + refreshToken, + ]); } /** @@ -204,7 +207,7 @@ export class AuthService { */ async revokeAllUserTokens(userId: number): Promise { UserDatabaseService.run( - "UPDATE refresh_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL", + 'UPDATE refresh_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL', [new Date().toISOString(), userId] ); @@ -240,10 +243,10 @@ export class AuthService { } // Revoke the old refresh token (security best practice) - UserDatabaseService.run( - "UPDATE refresh_tokens SET revoked_at = ? WHERE token = ?", - [new Date().toISOString(), refreshToken] - ); + UserDatabaseService.run('UPDATE refresh_tokens SET revoked_at = ? WHERE token = ?', [ + new Date().toISOString(), + refreshToken, + ]); logger.debug(`[AuthService] Revoked old refresh token for user ${user.id}`); @@ -278,7 +281,7 @@ export class AuthService { username: user.username, email: user.email, role: user.role_name, - permissions + permissions, }; } catch (error) { throw new AppError(401, 'Invalid or expired token'); @@ -305,7 +308,7 @@ export class AuthService { [userId] ); - const permissionNames = permissions.map(p => p.name); + const permissionNames = permissions.map((p) => p.name); // Store in cache PermissionCache.setUserPermissions(userId, permissionNames); @@ -320,7 +323,7 @@ export class AuthService { const accessToken = generateAccessToken({ userId: user.id, username: user.username, - role: user.role_name + role: user.role_name, }); const refreshToken = generateRefreshToken(); @@ -335,7 +338,7 @@ export class AuthService { return { accessToken, refreshToken, - expiresIn: 3600 // 1 hour + expiresIn: 3600, // 1 hour }; } @@ -349,7 +352,10 @@ export class AuthService { // Validate and sanitize lockoutDuration to prevent SQL injection // Ensure it's a number between 1 and 1440 (24 hours) - const safeLockoutDuration = Math.min(Math.max(parseInt(String(lockoutDuration), 10) || 15, 1), 1440); + const safeLockoutDuration = Math.min( + Math.max(parseInt(String(lockoutDuration), 10) || 15, 1), + 1440 + ); // Count failed attempts in lockout window - using parameterized query const failedAttempts = UserDatabaseService.get( @@ -361,14 +367,21 @@ export class AuthService { ); if (failedAttempts?.count >= maxAttempts) { - throw new AppError(429, `Too many login attempts. Please try again in ${safeLockoutDuration} minutes.`); + throw new AppError( + 429, + `Too many login attempts. Please try again in ${safeLockoutDuration} minutes.` + ); } } /** * Record login attempt */ - private async recordLoginAttempt(username: string, ipAddress: string, success: boolean): Promise { + private async recordLoginAttempt( + username: string, + ipAddress: string, + success: boolean + ): Promise { UserDatabaseService.run( 'INSERT INTO login_attempts (username, ip_address, success) VALUES (?, ?, ?)', [username, ipAddress, success ? 1 : 0] @@ -389,10 +402,20 @@ export class AuthService { // Return settings with defaults if not found return { - guest_access_enabled: authSettings.guestAccessEnabled !== undefined ? (authSettings.guestAccessEnabled ? 1 : 0) : 1, - user_registration_enabled: authSettings.userRegistrationEnabled !== undefined ? (authSettings.userRegistrationEnabled ? 1 : 0) : 1, + guest_access_enabled: + authSettings.guestAccessEnabled !== undefined + ? authSettings.guestAccessEnabled + ? 1 + : 0 + : 1, + user_registration_enabled: + authSettings.userRegistrationEnabled !== undefined + ? authSettings.userRegistrationEnabled + ? 1 + : 0 + : 1, max_login_attempts: authSettings.maxLoginAttempts || 5, - lockout_duration_minutes: authSettings.lockoutDurationMinutes || 15 + lockout_duration_minutes: authSettings.lockoutDurationMinutes || 15, }; } catch (error) { logger.error('Failed to get auth settings, using defaults:', error); @@ -401,7 +424,7 @@ export class AuthService { guest_access_enabled: 1, user_registration_enabled: 1, max_login_attempts: 5, - lockout_duration_minutes: 15 + lockout_duration_minutes: 15, }; } } diff --git a/backend/src/services/AuthSettingsService.ts b/backend/src/services/AuthSettingsService.ts index 6ba257e..9f1052b 100644 --- a/backend/src/services/AuthSettingsService.ts +++ b/backend/src/services/AuthSettingsService.ts @@ -24,14 +24,30 @@ export class AuthSettingsService { const latestUpdate = this.getLatestUpdate(); return { - guestAccessEnabled: typeof authSettings.guestAccessEnabled === 'boolean' ? authSettings.guestAccessEnabled : true, - userRegistrationEnabled: typeof authSettings.userRegistrationEnabled === 'boolean' ? authSettings.userRegistrationEnabled : true, - requireEmailVerification: typeof authSettings.requireEmailVerification === 'boolean' ? authSettings.requireEmailVerification : false, - sessionTimeoutMinutes: typeof authSettings.sessionTimeoutMinutes === 'number' ? authSettings.sessionTimeoutMinutes : 60, - maxLoginAttempts: typeof authSettings.maxLoginAttempts === 'number' ? authSettings.maxLoginAttempts : 5, - lockoutDurationMinutes: typeof authSettings.lockoutDurationMinutes === 'number' ? authSettings.lockoutDurationMinutes : 15, + guestAccessEnabled: + typeof authSettings.guestAccessEnabled === 'boolean' + ? authSettings.guestAccessEnabled + : true, + userRegistrationEnabled: + typeof authSettings.userRegistrationEnabled === 'boolean' + ? authSettings.userRegistrationEnabled + : true, + requireEmailVerification: + typeof authSettings.requireEmailVerification === 'boolean' + ? authSettings.requireEmailVerification + : false, + sessionTimeoutMinutes: + typeof authSettings.sessionTimeoutMinutes === 'number' + ? authSettings.sessionTimeoutMinutes + : 60, + maxLoginAttempts: + typeof authSettings.maxLoginAttempts === 'number' ? authSettings.maxLoginAttempts : 5, + lockoutDurationMinutes: + typeof authSettings.lockoutDurationMinutes === 'number' + ? authSettings.lockoutDurationMinutes + : 15, updatedAt: latestUpdate.updatedAt, - updatedBy: latestUpdate.updatedBy + updatedBy: latestUpdate.updatedBy, }; } @@ -69,7 +85,7 @@ export class AuthSettingsService { return { updatedAt: result?.updated_at || new Date().toISOString(), - updatedBy: result?.updated_by + updatedBy: result?.updated_by, }; } } diff --git a/backend/src/services/CachedSystemSettingsService.ts b/backend/src/services/CachedSystemSettingsService.ts index e662786..85b013e 100644 --- a/backend/src/services/CachedSystemSettingsService.ts +++ b/backend/src/services/CachedSystemSettingsService.ts @@ -42,7 +42,7 @@ export class CachedSystemSettingsService extends SystemSettingsService { if (value !== null) { this.cache.set(key, { value, - timestamp: Date.now() + timestamp: Date.now(), }); } @@ -64,7 +64,7 @@ export class CachedSystemSettingsService extends SystemSettingsService { this.categoryCache.set(category, { value, - timestamp: Date.now() + timestamp: Date.now(), }); return value; @@ -137,7 +137,7 @@ export class CachedSystemSettingsService extends SystemSettingsService { keyCount: this.cache.size, categoryCount: this.categoryCache.size, hitRate: 0, // Would need hit/miss tracking for accurate calculation - size: this.estimateCacheSize() + size: this.estimateCacheSize(), }; } diff --git a/backend/src/services/CommunityPlaylistService.ts b/backend/src/services/CommunityPlaylistService.ts index 378ff09..fbc9da8 100644 --- a/backend/src/services/CommunityPlaylistService.ts +++ b/backend/src/services/CommunityPlaylistService.ts @@ -46,18 +46,20 @@ export class CommunityPlaylistService { const response = await axios.get(PLAYLISTS_WIKI_URL, { timeout: 30000, headers: { - 'User-Agent': 'Flashpoint-Webapp/1.0' - } + 'User-Agent': 'Flashpoint-Webapp/1.0', + }, }); // Parse HTML and extract playlists const categories = this.parsePlaylistTables(response.data); - logger.info(`[CommunityPlaylist] Found ${categories.length} categories with ${categories.reduce((sum, cat) => sum + cat.playlists.length, 0)} total playlists`); + logger.info( + `[CommunityPlaylist] Found ${categories.length} categories with ${categories.reduce((sum, cat) => sum + cat.playlists.length, 0)} total playlists` + ); return { categories, - lastFetched: new Date().toISOString() + lastFetched: new Date().toISOString(), }; } catch (error) { logger.error('[CommunityPlaylist] Failed to fetch community playlists:', error); @@ -86,8 +88,8 @@ export class CommunityPlaylistService { const response = await axios.get(downloadUrl, { timeout: 60000, headers: { - 'User-Agent': 'Flashpoint-Webapp/1.0' - } + 'User-Agent': 'Flashpoint-Webapp/1.0', + }, }); const playlistData = response.data; @@ -97,7 +99,7 @@ export class CommunityPlaylistService { logger.error('[CommunityPlaylist] Invalid playlist structure'); return { success: false, - error: 'Invalid playlist format' + error: 'Invalid playlist format', }; } @@ -112,7 +114,7 @@ export class CommunityPlaylistService { return { success: false, conflict: true, - error: 'A playlist with this ID already exists' + error: 'A playlist with this ID already exists', }; } catch { // File doesn't exist - good to proceed @@ -121,11 +123,13 @@ export class CommunityPlaylistService { // Save playlist to disk await fs.writeFile(playlistFilePath, JSON.stringify(playlistData, null, '\t'), 'utf-8'); - logger.info(`[CommunityPlaylist] Downloaded playlist: ${playlistData.title} (${playlistData.id})`); + logger.info( + `[CommunityPlaylist] Downloaded playlist: ${playlistData.title} (${playlistData.id})` + ); return { success: true, - playlist: playlistData + playlist: playlistData, }; } catch (error) { logger.error('[CommunityPlaylist] Failed to download playlist:', error); @@ -134,26 +138,26 @@ export class CommunityPlaylistService { if (error.code === 'ECONNABORTED') { return { success: false, - error: 'Download timeout - please try again' + error: 'Download timeout - please try again', }; } if (error.response?.status === 404) { return { success: false, - error: 'Playlist not found on server' + error: 'Playlist not found on server', }; } if (error.response?.status && error.response.status >= 500) { return { success: false, - error: 'Server error - please try again later' + error: 'Server error - please try again later', }; } } return { success: false, - error: 'Failed to download playlist' + error: 'Failed to download playlist', }; } } @@ -177,9 +181,11 @@ export class CommunityPlaylistService { const categoryName = headerText.replace(/\[edit.*?\]/g, '').trim(); // Skip certain headers - if (categoryName.toLowerCase().includes('contents') || - categoryName.toLowerCase().includes('navigation') || - categoryName.toLowerCase().includes('tutorial')) { + if ( + categoryName.toLowerCase().includes('contents') || + categoryName.toLowerCase().includes('navigation') || + categoryName.toLowerCase().includes('tutorial') + ) { return; } @@ -202,36 +208,39 @@ export class CommunityPlaylistService { const playlists: CommunityPlaylist[] = []; // Parse table rows (skip header row) - $(elem).find('tr').slice(1).each((j, row) => { - const cells = $(row).find('td'); + $(elem) + .find('tr') + .slice(1) + .each((j, row) => { + const cells = $(row).find('td'); - if (cells.length >= 4) { - const name = $(cells[0]).text().trim(); - const author = $(cells[1]).text().trim(); - const description = $(cells[2]).text().trim(); - const downloadLink = $(cells[3]).find('a').attr('href'); + if (cells.length >= 4) { + const name = $(cells[0]).text().trim(); + const author = $(cells[1]).text().trim(); + const description = $(cells[2]).text().trim(); + const downloadLink = $(cells[3]).find('a').attr('href'); - if (name && downloadLink) { - // Make URL absolute - const absoluteUrl = this.makeAbsoluteUrl(downloadLink); + if (name && downloadLink) { + // Make URL absolute + const absoluteUrl = this.makeAbsoluteUrl(downloadLink); - // Determine category name - let categoryName = mainCategory; - if (mainCategory === 'Games' && subCategory) { - // For Games category, create separate categories for each subcategory - categoryName = `Games - ${subCategory}`; + // Determine category name + let categoryName = mainCategory; + if (mainCategory === 'Games' && subCategory) { + // For Games category, create separate categories for each subcategory + categoryName = `Games - ${subCategory}`; + } + + playlists.push({ + name, + author: author || 'Unknown', + description: description || '', + downloadUrl: absoluteUrl, + category: categoryName, + }); } - - playlists.push({ - name, - author: author || 'Unknown', - description: description || '', - downloadUrl: absoluteUrl, - category: categoryName - }); } - } - }); + }); // Only add category if it has playlists (non-empty) if (playlists.length > 0) { @@ -242,11 +251,11 @@ export class CommunityPlaylistService { } // Find or create category - let category = categories.find(cat => cat.name === categoryName); + let category = categories.find((cat) => cat.name === categoryName); if (!category) { category = { name: categoryName, - playlists: [] + playlists: [], }; categories.push(category); } @@ -258,7 +267,7 @@ export class CommunityPlaylistService { }); // Filter out empty categories - return categories.filter(cat => cat.playlists.length > 0); + return categories.filter((cat) => cat.playlists.length > 0); } /** diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 1f882fb..005c675 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -52,7 +52,7 @@ export class DatabaseService { logger.info('Database connection opened successfully', { sourcePath: this.sourceDbPath, activePath: this.activeDbPath, - usingLocalCopy: this.isUsingLocalCopy + usingLocalCopy: this.isUsingLocalCopy, }); // Start watching source database for changes @@ -128,7 +128,7 @@ export class DatabaseService { mmapSize: this.isUsingLocalCopy ? config.sqliteMmapSize : 0, tempStore: 'MEMORY', journalMode: 'DELETE', - synchronous: 'NORMAL' + synchronous: 'NORMAL', }); } catch (error) { logger.error('Failed to apply some PRAGMA optimizations:', error); @@ -180,7 +180,7 @@ export class DatabaseService { if (sourceStats.mtimeMs > localStats.mtimeMs) { logger.info('Source database is newer than local copy, will update', { sourceModified: new Date(sourceStats.mtimeMs).toISOString(), - localModified: new Date(localStats.mtimeMs).toISOString() + localModified: new Date(localStats.mtimeMs).toISOString(), }); return true; } @@ -189,7 +189,7 @@ export class DatabaseService { if (sourceStats.size !== localStats.size) { logger.info('Source database size differs from local copy, will update', { sourceSize: sourceStats.size, - localSize: localStats.size + localSize: localStats.size, }); return true; } @@ -211,7 +211,7 @@ export class DatabaseService { try { logger.info('Copying database to local storage...', { source: this.sourceDbPath, - destination: config.localDbPath + destination: config.localDbPath, }); // Copy to temp file first (atomic operation) @@ -226,7 +226,7 @@ export class DatabaseService { logger.info(`Database copied to local storage in ${duration}ms`, { size: `${sizeMB} MB`, - path: config.localDbPath + path: config.localDbPath, }); } catch (error) { // Clean up temp file if it exists @@ -266,7 +266,7 @@ export class DatabaseService { }); logger.info('Database file watcher started', { - watching: this.sourceDbPath + watching: this.sourceDbPath, }); } catch (error) { logger.error('Failed to start database file watcher:', error); @@ -418,7 +418,7 @@ export class DatabaseService { sourcePath: this.sourceDbPath, activePath: this.activeDbPath, usingLocalCopy: this.isUsingLocalCopy, - lastModified: this.lastModifiedTime ? new Date(this.lastModifiedTime) : null + lastModified: this.lastModifiedTime ? new Date(this.lastModifiedTime) : null, }; } @@ -437,61 +437,77 @@ export class DatabaseService { static exec(sql: string, params: any[] = []): T[] { const db = this.getDatabase(); - return measureQueryPerformance(() => { - try { - const stmt = db.prepare(sql); - return stmt.all(params) as T[]; - } catch (error) { - logger.error('Database query error:', { sql, params, error }); - throw error; - } - }, sql, params); + return measureQueryPerformance( + () => { + try { + const stmt = db.prepare(sql); + return stmt.all(params) as T[]; + } catch (error) { + logger.error('Database query error:', { sql, params, error }); + throw error; + } + }, + sql, + params + ); } // Helper method to get a single row static get(sql: string, params: any[] = []): T | undefined { const db = this.getDatabase(); - return measureQueryPerformance(() => { - try { - const stmt = db.prepare(sql); - const result = stmt.get(params); - return result as T | undefined; - } catch (error) { - logger.error('Database get error:', error); - throw error; - } - }, sql, params); + return measureQueryPerformance( + () => { + try { + const stmt = db.prepare(sql); + const result = stmt.get(params); + return result as T | undefined; + } catch (error) { + logger.error('Database get error:', error); + throw error; + } + }, + sql, + params + ); } // Helper method to get all rows static all(sql: string, params: any[] = []): T[] { const db = this.getDatabase(); - return measureQueryPerformance(() => { - try { - const stmt = db.prepare(sql); - return stmt.all(params) as T[]; - } catch (error) { - logger.error('Database all error:', error); - throw error; - } - }, sql, params); + return measureQueryPerformance( + () => { + try { + const stmt = db.prepare(sql); + return stmt.all(params) as T[]; + } catch (error) { + logger.error('Database all error:', error); + throw error; + } + }, + sql, + params + ); } // Helper method to run INSERT/UPDATE/DELETE queries (doesn't return rows) static run(sql: string, params: any[] = []): void { const db = this.getDatabase(); - measureQueryPerformance(() => { - try { - const stmt = db.prepare(sql); - stmt.run(params); - } catch (error) { - logger.error('Database run error:', { sql, params, error }); - throw error; - } - }, sql, params); + measureQueryPerformance( + () => { + try { + const stmt = db.prepare(sql); + stmt.run(params); + } catch (error) { + logger.error('Database run error:', { sql, params, error }); + throw error; + } + }, + sql, + params + ); } // Save database changes to disk diff --git a/backend/src/services/DomainService.ts b/backend/src/services/DomainService.ts index 89fad79..a970f41 100644 --- a/backend/src/services/DomainService.ts +++ b/backend/src/services/DomainService.ts @@ -50,7 +50,9 @@ export class DomainService { } const rows = this.db - .prepare('SELECT id, hostname, is_default, created_at FROM domains ORDER BY is_default DESC, hostname ASC') + .prepare( + 'SELECT id, hostname, is_default, created_at FROM domains ORDER BY is_default DESC, hostname ASC' + ) .all() as DomainRow[]; cachedDomains = rows.map(this.mapRow); @@ -102,17 +104,15 @@ export class DomainService { const validated = this.validateHostname(hostname); // Check for duplicate - const existing = this.db - .prepare('SELECT id FROM domains WHERE hostname = ?') - .get(validated) as { id: number } | undefined; + const existing = this.db.prepare('SELECT id FROM domains WHERE hostname = ?').get(validated) as + | { id: number } + | undefined; if (existing) { throw new Error(`Domain "${validated}" already exists`); } - const stmt = this.db.prepare( - 'INSERT INTO domains (hostname, created_by) VALUES (?, ?)' - ); + const stmt = this.db.prepare('INSERT INTO domains (hostname, created_by) VALUES (?, ?)'); const result = stmt.run(validated, createdBy); this.invalidateCache(); @@ -129,9 +129,9 @@ export class DomainService { * Delete a domain by ID */ deleteDomain(id: number): boolean { - const existing = this.db - .prepare('SELECT hostname FROM domains WHERE id = ?') - .get(id) as { hostname: string } | undefined; + const existing = this.db.prepare('SELECT hostname FROM domains WHERE id = ?').get(id) as + | { hostname: string } + | undefined; if (!existing) { return false; diff --git a/backend/src/services/DownloadManager.ts b/backend/src/services/DownloadManager.ts index f2f3ca3..b445175 100644 --- a/backend/src/services/DownloadManager.ts +++ b/backend/src/services/DownloadManager.ts @@ -99,7 +99,7 @@ export class DownloadManager { gameDataId, gameId: gameData.gameId, filename, - sourceCount: sources.length + sourceCount: sources.length, }); // Collect errors from failed sources @@ -112,7 +112,7 @@ export class DownloadManager { try { logger.info(`Attempting download from source ${i + 1}/${sources.length}`, { source: source.name, - gameDataId + gameDataId, }); // Update progress @@ -123,7 +123,7 @@ export class DownloadManager { filename, source: source.name, bytesDownloaded: 0, - totalBytes: 0 + totalBytes: 0, }); // Construct URL @@ -131,27 +131,22 @@ export class DownloadManager { logger.debug('Download URL constructed', { url, source: source.name }); // Download to temp file - await this.downloadFile( - url, - tempFilePath, - controller.signal, - (downloaded, total) => { - const percent = total > 0 ? Math.floor((downloaded / total) * 100) : 0; - onProgress?.(percent); - onDetails?.({ - gameId: gameData.gameId, - gameDataId, - filename, - source: source.name, - bytesDownloaded: downloaded, - totalBytes: total - }); - } - ); + await this.downloadFile(url, tempFilePath, controller.signal, (downloaded, total) => { + const percent = total > 0 ? Math.floor((downloaded / total) * 100) : 0; + onProgress?.(percent); + onDetails?.({ + gameId: gameData.gameId, + gameDataId, + filename, + source: source.name, + bytesDownloaded: downloaded, + totalBytes: total, + }); + }); logger.info('Download completed, validating hash', { gameDataId, - source: source.name + source: source.name, }); // Update status to validating @@ -161,7 +156,7 @@ export class DownloadManager { filename, source: source.name, bytesDownloaded: 0, - totalBytes: 0 + totalBytes: 0, }); // Validate hash @@ -169,7 +164,7 @@ export class DownloadManager { logger.info('Hash validation successful, importing file', { gameDataId, - source: source.name + source: source.name, }); // Import to final location @@ -181,7 +176,7 @@ export class DownloadManager { logger.info('Download workflow completed successfully', { gameDataId, finalPath, - source: source.name + source: source.name, }); // Success! Return final path @@ -191,12 +186,12 @@ export class DownloadManager { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; logger.warn(`Download failed from source: ${source.name}`, { gameDataId, - error: errorMessage + error: errorMessage, }); errors.push({ source: source.name, - error: errorMessage + error: errorMessage, }); // Clean up temp file for next attempt @@ -213,9 +208,7 @@ export class DownloadManager { } // All sources failed - const errorSummary = errors - .map(e => `${e.source}: ${e.error}`) - .join('\n'); + const errorSummary = errors.map((e) => `${e.source}: ${e.error}`).join('\n'); throw new Error( `Failed to download game data from all ${sources.length} sources:\n${errorSummary}` @@ -245,8 +238,8 @@ export class DownloadManager { timeout: this.DOWNLOAD_TIMEOUT_MS, signal: abortSignal as any, headers: { - 'User-Agent': 'Flashpoint-WebApp/1.0' - } + 'User-Agent': 'Flashpoint-WebApp/1.0', + }, }); const totalBytes = parseInt(response.headers['content-length'] || '0', 10); diff --git a/backend/src/services/FallbackCache.ts b/backend/src/services/FallbackCache.ts index 4a8058b..b5c0f20 100644 --- a/backend/src/services/FallbackCache.ts +++ b/backend/src/services/FallbackCache.ts @@ -16,7 +16,7 @@ export class FallbackCache { max: maxSize, ttl: this.FALLBACK_TTL, updateAgeOnGet: true, - allowStale: true // Allow stale data in fallback mode + allowStale: true, // Allow stale data in fallback mode }); } @@ -26,7 +26,7 @@ export class FallbackCache { set(key: string, data: T): void { this.cache.set(key, { data, - cachedAt: Date.now() + cachedAt: Date.now(), }); } @@ -45,13 +45,13 @@ export class FallbackCache { logger.debug('[FallbackCache] Serving cached data', { key: key.substring(0, 50), age: `${Math.round(age / 1000)}s`, - cachedAt: new Date(cached.cachedAt).toISOString() + cachedAt: new Date(cached.cachedAt).toISOString(), }); return { data: cached.data, cachedAt: cached.cachedAt, - age + age, }; } @@ -75,7 +75,7 @@ export class FallbackCache { getStats(): { size: number; max: number } { return { size: this.cache.size, - max: this.cache.max + max: this.cache.max, }; } } diff --git a/backend/src/services/FavoritesService.ts b/backend/src/services/FavoritesService.ts index 8826a79..fe14ef6 100644 --- a/backend/src/services/FavoritesService.ts +++ b/backend/src/services/FavoritesService.ts @@ -219,7 +219,7 @@ export class FavoritesService { if (game) { return { ...game, - addedAt: fav.added_at + addedAt: fav.added_at, }; } return null; diff --git a/backend/src/services/FileImporter.ts b/backend/src/services/FileImporter.ts index 03f9a2b..bf0fd37 100644 --- a/backend/src/services/FileImporter.ts +++ b/backend/src/services/FileImporter.ts @@ -40,7 +40,7 @@ export class FileImporter { logger.info('Starting file import', { gameId, tempFile: tempFilePath, - size: stats.size + size: stats.size, }); // Get destination directory @@ -63,13 +63,15 @@ export class FileImporter { logger.info('File copied successfully', { gameId, finalPath, - size: stats.size + size: stats.size, }); // Verify copy succeeded const finalStats = await fs.promises.stat(finalPath); if (finalStats.size !== stats.size) { - throw new Error(`File copy verification failed: size mismatch (expected ${stats.size}, got ${finalStats.size})`); + throw new Error( + `File copy verification failed: size mismatch (expected ${stats.size}, got ${finalStats.size})` + ); } // Delete temporary file @@ -77,7 +79,7 @@ export class FileImporter { logger.info('File import completed successfully', { gameId, - finalPath + finalPath, }); return finalPath; @@ -85,7 +87,7 @@ export class FileImporter { logger.error('File import failed', { gameId, tempFilePath, - error + error, }); // Re-throw with more context @@ -117,7 +119,7 @@ export class FileImporter { } catch (error) { logger.warn('Failed to delete temporary file', { tempFilePath, - error + error, }); // Don't throw - cleanup is best-effort } diff --git a/backend/src/services/GameDataService.ts b/backend/src/services/GameDataService.ts index 0282fd8..10ecaec 100644 --- a/backend/src/services/GameDataService.ts +++ b/backend/src/services/GameDataService.ts @@ -26,9 +26,7 @@ export class GameDataService { // Look for ZIP file starting with gameId const zipPattern = `${gameId}-`; - const zipFile = files.find(f => - f.startsWith(zipPattern) && f.endsWith('.zip') - ); + const zipFile = files.find((f) => f.startsWith(zipPattern) && f.endsWith('.zip')); if (!zipFile) { logger.warn(`[GameDataService] No ZIP file found for game ${gameId}`); @@ -37,17 +35,23 @@ export class GameDataService { const zipPath = path.join(gamesPath, zipFile); - logger.info(`[GameDataService] Requesting game-service to mount ZIP for game ${gameId}: ${zipFile}`); + logger.info( + `[GameDataService] Requesting game-service to mount ZIP for game ${gameId}: ${zipFile}` + ); // Call game-service API to mount the ZIP const gameZipServerUrl = config.gameServiceGameZipUrl || 'http://localhost:22501'; try { - const response = await axios.post(`${gameZipServerUrl}/mount/${mountId}`, { - zipPath: zipPath - }, { - timeout: 5000, - validateStatus: (status) => status === 200 || status === 409 // 409 = already mounted - }); + const response = await axios.post( + `${gameZipServerUrl}/mount/${mountId}`, + { + zipPath: zipPath, + }, + { + timeout: 5000, + validateStatus: (status) => status === 200 || status === 409, // 409 = already mounted + } + ); if (response.status === 409) { logger.debug(`[GameDataService] ZIP already mounted for game ${gameId}`); @@ -64,7 +68,6 @@ export class GameDataService { logger.error(`[GameDataService] Failed to mount ZIP via game-service:`, error); return false; } - } catch (error) { logger.error(`[GameDataService] Error mounting ZIP for game ${gameId}:`, error); return false; @@ -87,23 +90,30 @@ export class GameDataService { const gameDataResults = await Promise.allSettled(gameDataPromises); const validGameData = gameDataResults - .filter((r): r is PromiseFulfilledResult<{ gameId: string; activeDataId: number } | null> => r.status === 'fulfilled' && r.value !== null) - .map(r => r.value!); + .filter( + (r): r is PromiseFulfilledResult<{ gameId: string; activeDataId: number } | null> => + r.status === 'fulfilled' && r.value !== null + ) + .map((r) => r.value!); const results = await Promise.allSettled( validGameData.map(({ gameId }) => this.mountGameZip(gameId)) ); - const succeeded = results.filter(r => r.status === 'fulfilled' && r.value).length; + const succeeded = results.filter((r) => r.status === 'fulfilled' && r.value).length; const failed = results.length - succeeded; - logger.info(`[GameDataService] Mounted ${succeeded}/${results.length} game ZIPs (${failed} failed)`); + logger.info( + `[GameDataService] Mounted ${succeeded}/${results.length} game ZIPs (${failed} failed)` + ); } /** * Get list of all available game data ZIP files */ - async listAvailableGameDataZips(): Promise> { + async listAvailableGameDataZips(): Promise< + Array<{ activeDataId: number; filename: string; size: number }> + > { try { const gamesPath = config.flashpointGamesPath; const files = await fs.readdir(gamesPath); @@ -122,7 +132,7 @@ export class GameDataService { zipFiles.push({ activeDataId, filename: file, - size: stats.size + size: stats.size, }); } } diff --git a/backend/src/services/GameDatabaseUpdater.ts b/backend/src/services/GameDatabaseUpdater.ts index a0ce646..3a9c2e0 100644 --- a/backend/src/services/GameDatabaseUpdater.ts +++ b/backend/src/services/GameDatabaseUpdater.ts @@ -34,7 +34,7 @@ export class GameDatabaseUpdater { try { logger.info('Updating database for downloaded game data', { gameDataId, - filePath + filePath, }); // First, fetch the current game data to get the gameId @@ -50,7 +50,7 @@ export class GameDatabaseUpdater { logger.debug('Path conversion for database storage', { absolutePath: filePath, - relativePath + relativePath, }); // Update game_data table @@ -62,7 +62,7 @@ export class GameDatabaseUpdater { logger.debug('Executing UPDATE on game_data', { sql: updateGameDataSql, - params: [relativePath, gameDataId] + params: [relativePath, gameDataId], }); DatabaseService.run(updateGameDataSql, [relativePath, gameDataId]); @@ -87,19 +87,19 @@ export class GameDatabaseUpdater { logger.debug('Executing UPDATE on game table', { sql: updateGameSql, - params: [gameData.gameId, gameDataId] + params: [gameData.gameId, gameDataId], }); DatabaseService.run(updateGameSql, [gameData.gameId, gameDataId]); logger.debug('game table UPDATE successful - activeDataOnDisk set to 1', { - gameId: gameData.gameId + gameId: gameData.gameId, }); } else { logger.debug('Skipping game table update - game data is not active', { gameId: gameData.gameId, activeDataId: game?.activeDataId, - requestedDataId: gameDataId + requestedDataId: gameDataId, }); } @@ -109,7 +109,7 @@ export class GameDatabaseUpdater { logger.info('Database updated successfully', { gameDataId, gameId: gameData.gameId, - filePath + filePath, }); // Fetch and return updated game data @@ -123,12 +123,14 @@ export class GameDatabaseUpdater { logger.error('Failed to update database', { gameDataId, filePath, - error + error, }); // Attempt to rollback by reloading database (sql.js limitation) // In a real scenario, we'd use a proper transaction system - throw new Error(`Database update failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + throw new Error( + `Database update failed: ${error instanceof Error ? error.message : 'Unknown error'}` + ); } } @@ -176,11 +178,13 @@ export class GameDatabaseUpdater { DatabaseService.save(); logger.info('Database changes flushed to disk', { - path: config.flashpointDbPath + path: config.flashpointDbPath, }); } catch (error) { logger.error('Failed to save database to disk', { error }); - throw new Error(`Failed to persist database changes: ${error instanceof Error ? error.message : 'Unknown error'}`); + throw new Error( + `Failed to persist database changes: ${error instanceof Error ? error.message : 'Unknown error'}` + ); } } @@ -198,7 +202,7 @@ export class GameDatabaseUpdater { presentOnDisk: row.presentOnDisk, path: row.path, size: row.size, - parameters: row.parameters + parameters: row.parameters, }; } diff --git a/backend/src/services/GameFileService.ts b/backend/src/services/GameFileService.ts index f2e782e..07ed139 100644 --- a/backend/src/services/GameFileService.ts +++ b/backend/src/services/GameFileService.ts @@ -54,7 +54,7 @@ export class GameFileService { this.infinityServerURL = 'http://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs/'; this.externalFilePaths = [ 'http://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs', - 'http://infinity.unstable.life/Flashpoint/Legacy/htdocs/' + 'http://infinity.unstable.life/Flashpoint/Legacy/htdocs/', ]; } } @@ -64,7 +64,9 @@ export class GameFileService { * @param urlPath The path like "www.example.com/path/file.swf" * @returns File buffer and content type */ - async getGameFile(urlPath: string): Promise<{ data: Buffer; contentType: string; source: string }> { + async getGameFile( + urlPath: string + ): Promise<{ data: Buffer; contentType: string; source: string }> { logger.info(`[GameFileService] ========================================`); logger.info(`[GameFileService] Requested: ${urlPath}`); @@ -96,7 +98,9 @@ export class GameFileService { // Try other external paths let attemptNum = 3; for (const externalPath of this.externalFilePaths) { - logger.info(`[GameFileService] [${attemptNum}/${3 + this.externalFilePaths.length}] Trying: ${externalPath}`); + logger.info( + `[GameFileService] [${attemptNum}/${3 + this.externalFilePaths.length}] Trying: ${externalPath}` + ); const result = await this.tryExternalSource(externalPath, urlPath); if (result) { logger.info(`[GameFileService] ✓ Found in external source!`); @@ -105,14 +109,18 @@ export class GameFileService { attemptNum++; } - logger.error(`[GameFileService] ✗ File not found in any source (tried ${2 + this.externalFilePaths.length} sources)`); + logger.error( + `[GameFileService] ✗ File not found in any source (tried ${2 + this.externalFilePaths.length} sources)` + ); throw new Error('File not found in Game Server or any external source'); } /** * Try to get file from the local Game Server */ - private async tryGameServer(urlPath: string): Promise<{ data: Buffer; contentType: string } | null> { + private async tryGameServer( + urlPath: string + ): Promise<{ data: Buffer; contentType: string } | null> { return new Promise((resolve, reject) => { // Make proxy-style HTTP request const fullUrl = `http://${urlPath}`; @@ -124,7 +132,7 @@ export class GameFileService { path: fullUrl, method: 'GET', headers: { - 'Host': parsedUrl.hostname, + Host: parsedUrl.hostname, 'User-Agent': 'Flashpoint-WebApp/1.0', }, timeout: 10000, @@ -163,7 +171,10 @@ export class GameFileService { /** * Try to download file from an external source */ - private async tryExternalSource(baseUrl: string, urlPath: string): Promise<{ data: Buffer; contentType: string } | null> { + private async tryExternalSource( + baseUrl: string, + urlPath: string + ): Promise<{ data: Buffer; contentType: string } | null> { // Construct full URL // baseUrl might be: http://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs/ // urlPath is: www.example.com/path/file.swf @@ -209,7 +220,9 @@ export class GameFileService { } else if (error.code === 'ENOTFOUND') { logger.warn(`[GameFileService] ✗ DNS resolution failed`); } else if (error.response) { - logger.warn(`[GameFileService] ✗ HTTP ${error.response.status}: ${error.response.statusText}`); + logger.warn( + `[GameFileService] ✗ HTTP ${error.response.status}: ${error.response.statusText}` + ); } else if (error.request) { logger.warn(`[GameFileService] ✗ No response received: ${error.message}`); } else { diff --git a/backend/src/services/GameSearchCache.ts b/backend/src/services/GameSearchCache.ts index 2d7863a..26f7142 100644 --- a/backend/src/services/GameSearchCache.ts +++ b/backend/src/services/GameSearchCache.ts @@ -37,7 +37,7 @@ export class GameSearchCache { // Optional: Log evictions for monitoring dispose: (value, key) => { logger.debug(`[GameSearchCache] Evicted cache entry: ${key}`); - } + }, }); private static gameService = new GameService(); @@ -68,7 +68,7 @@ export class GameSearchCache { limit: query.limit, showBroken: query.showBroken, showExtreme: query.showExtreme, - fields: query.fields || 'detail' + fields: query.fields || 'detail', }; return JSON.stringify(normalizedQuery); @@ -79,7 +79,9 @@ export class GameSearchCache { * Checks cache first, falls back to database query if cache miss * If database fails, returns stale data from fallback cache */ - static async searchGames(query: GameSearchQuery): Promise & { fromCache?: boolean; cacheAge?: number }> { + static async searchGames( + query: GameSearchQuery + ): Promise & { fromCache?: boolean; cacheAge?: number }> { const cacheKey = this.generateCacheKey(query); // Try to get from primary cache @@ -105,7 +107,7 @@ export class GameSearchCache { if (!result || !result.data || !Array.isArray(result.data)) { logger.error('[GameSearchCache] Invalid result from database query', { result, - cacheKey: cacheKey.substring(0, 200) + cacheKey: cacheKey.substring(0, 200), }); throw new Error('Invalid database query result'); } @@ -118,21 +120,21 @@ export class GameSearchCache { } catch (error) { // Database query failed - try fallback cache logger.warn('[GameSearchCache] Database query failed, attempting fallback cache', { - error: error instanceof Error ? error.message : String(error) + error: error instanceof Error ? error.message : String(error), }); const fallback = this.fallbackCache.get(cacheKey); if (fallback) { logger.info('[GameSearchCache] Serving stale data from fallback cache', { age: `${Math.round(fallback.age / 1000)}s`, - cachedAt: new Date(fallback.cachedAt).toISOString() + cachedAt: new Date(fallback.cachedAt).toISOString(), }); // Return stale data with metadata return { ...fallback.data, fromCache: true, - cacheAge: fallback.age + cacheAge: fallback.age, }; } @@ -150,7 +152,9 @@ export class GameSearchCache { static clearCache(): void { const size = this.cache.size; this.cache.clear(); - logger.info(`[GameSearchCache] Cleared primary cache (${size} entries), fallback cache preserved`); + logger.info( + `[GameSearchCache] Cleared primary cache (${size} entries), fallback cache preserved` + ); } /** @@ -163,7 +167,9 @@ export class GameSearchCache { this.cache.clear(); this.fallbackCache.clear(); - logger.info(`[GameSearchCache] Cleared all caches (primary: ${primarySize}, fallback: ${fallbackSize} entries)`); + logger.info( + `[GameSearchCache] Cleared all caches (primary: ${primarySize}, fallback: ${fallbackSize} entries)` + ); } /** @@ -177,9 +183,9 @@ export class GameSearchCache { primary: { size: this.cache.size, max: this.cache.max, - ttl: 1000 * 60 * 5 // 5 minutes + ttl: 1000 * 60 * 5, // 5 minutes }, - fallback: this.fallbackCache.getStats() + fallback: this.fallbackCache.getStats(), }; } @@ -216,9 +222,23 @@ export class GameSearchCache { // Default home page - animations library { library: 'theatre', page: 1, limit: 50, sortBy: 'title', sortOrder: 'asc', fields: 'list' }, // Recently added games - { library: 'arcade', page: 1, limit: 50, sortBy: 'dateAdded', sortOrder: 'desc', fields: 'list' }, + { + library: 'arcade', + page: 1, + limit: 50, + sortBy: 'dateAdded', + sortOrder: 'desc', + fields: 'list', + }, // Recently added animations - { library: 'theatre', page: 1, limit: 50, sortBy: 'dateAdded', sortOrder: 'desc', fields: 'list' }, + { + library: 'theatre', + page: 1, + limit: 50, + sortBy: 'dateAdded', + sortOrder: 'desc', + fields: 'list', + }, ]; let successCount = 0; @@ -234,7 +254,7 @@ export class GameSearchCache { fields: 'list', showBroken: false, showExtreme: false, - ...partialQuery + ...partialQuery, }; try { @@ -244,14 +264,14 @@ export class GameSearchCache { logger.debug(`[GameSearchCache] Pre-warmed query in ${queryDuration}ms`, { library: query.library, - sortBy: query.sortBy + sortBy: query.sortBy, }); successCount++; } catch (error) { logger.warn('[GameSearchCache] Failed to pre-warm query', { query: partialQuery, - error: error instanceof Error ? error.message : String(error) + error: error instanceof Error ? error.message : String(error), }); failCount++; } @@ -261,7 +281,7 @@ export class GameSearchCache { logger.info(`[GameSearchCache] Cache pre-warming completed in ${totalDuration}ms`, { success: successCount, failed: failCount, - total: commonQueries.length + total: commonQueries.length, }); } @@ -279,7 +299,7 @@ export class GameSearchCache { sortOrder: 'asc', fields: 'list', showBroken: false, - showExtreme: false + showExtreme: false, }; return this.has(defaultQuery); } diff --git a/backend/src/services/GameService.ts b/backend/src/services/GameService.ts index c63ae18..adfe309 100644 --- a/backend/src/services/GameService.ts +++ b/backend/src/services/GameService.ts @@ -58,7 +58,7 @@ export interface Game { playtime?: number; playCounter?: number; archiveState?: number; - logoPath?: string; // Infinity edition only + logoPath?: string; // Infinity edition only screenshotPath?: string; // Infinity edition only } @@ -106,8 +106,11 @@ export class GameService { // Infinity edition has logoPath and screenshotPath columns if (isInfinity) { - return baseColumns + `, - g.logoPath, g.screenshotPath`; + return ( + baseColumns + + `, + g.logoPath, g.screenshotPath` + ); } return baseColumns; @@ -183,7 +186,7 @@ export class GameService { // OPTIMIZATION: Use INSTR instead of LIKE for tag matching (faster in SQLite) if (query.tags && query.tags.length > 0) { // Check if tags are in tagsStr field using INSTR with semicolon boundaries - query.tags.forEach(tag => { + query.tags.forEach((tag) => { sql += ` AND INSTR(';' || g.tagsStr || ';', ?) > 0`; params.push(`;${tag};`); }); @@ -245,7 +248,7 @@ export class GameService { dateAddedSince: query.dateAddedSince, dateModifiedSince: query.dateModifiedSince, sortBy: query.sortBy, - params: params + params: params, }); } @@ -258,7 +261,7 @@ export class GameService { // OPTIMIZATION: Fetch presentOnDisk separately for returned games (post-processing pattern) // This avoids the LEFT JOIN row explosion and GROUP BY aggregation if (games.length > 0) { - const gameIds = games.map(g => g.id); + const gameIds = games.map((g) => g.id); const placeholders = gameIds.map(() => '?').join(', '); const presentOnDiskSql = ` @@ -275,7 +278,7 @@ export class GameService { // Create a map for O(1) lookup const presentOnDiskMap = new Map( - presentOnDiskResults.map(r => [r.gameId, r.presentOnDisk]) + presentOnDiskResults.map((r) => [r.gameId, r.presentOnDisk]) ); // Merge presentOnDisk into game objects and remove total_count @@ -290,7 +293,7 @@ export class GameService { total, page: query.page, limit: query.limit, - totalPages: Math.ceil(total / query.limit) + totalPages: Math.ceil(total / query.limit), }; } catch (error) { logger.error('Error searching games:', { @@ -301,8 +304,8 @@ export class GameService { sortBy: query.sortBy, library: query.library, page: query.page, - limit: query.limit - } + limit: query.limit, + }, }); throw error; } @@ -354,8 +357,8 @@ export class GameService { const games = DatabaseService.all(sql, ids) as Game[]; // Sort games to match the order of input IDs - const gamesMap = new Map(games.map(g => [g.id, g])); - return ids.map(id => gamesMap.get(id)).filter((g): g is Game => g !== undefined); + const gamesMap = new Map(games.map((g) => [g.id, g])); + return ids.map((id) => gamesMap.get(id)).filter((g): g is Game => g !== undefined); } catch (error) { logger.error('Error getting games by IDs:', error); throw error; @@ -460,7 +463,7 @@ export class GameService { sourceGame.platformName, sourceGame.developer, sourceGame.platformName, - limit + limit, ]) as Game[]; return games; @@ -504,7 +507,7 @@ export class GameService { releaseDate: 'g.releaseDate', dateAdded: 'g.dateAdded', dateModified: 'g.dateModified', - developer: 'g.developer' + developer: 'g.developer', }; return columnMap[sortBy] || 'g.orderTitle'; } @@ -697,7 +700,10 @@ export class GameService { const tagCounts = new Map(); for (const row of results) { - const tags = row.tagsStr.split(';').map(tag => tag.trim()).filter(tag => tag); + const tags = row.tagsStr + .split(';') + .map((tag) => tag.trim()) + .filter((tag) => tag); for (const tag of tags) { tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1); } @@ -792,7 +798,7 @@ export class GameService { } // Get game IDs for query - const gameIds = mostPlayedStats.map(stat => stat.game_id); + const gameIds = mostPlayedStats.map((stat) => stat.game_id); // Fetch full game details from flashpoint database using list fields const placeholders = gameIds.map(() => '?').join(','); @@ -805,9 +811,9 @@ export class GameService { const games = DatabaseService.all(sql, gameIds) as Game[]; // Sort games by the original play count order - const gameMap = new Map(games.map(game => [game.id, game])); + const gameMap = new Map(games.map((game) => [game.id, game])); const sortedGames = gameIds - .map(id => gameMap.get(id)) + .map((id) => gameMap.get(id)) .filter((game): game is Game => game !== undefined); return sortedGames; diff --git a/backend/src/services/HashValidator.ts b/backend/src/services/HashValidator.ts index c321d94..14ceaf6 100644 --- a/backend/src/services/HashValidator.ts +++ b/backend/src/services/HashValidator.ts @@ -59,7 +59,7 @@ export class HashValidator { logger.warn('Hash validation failed', { filePath, expected, - actual: actualHash + actual: actualHash, }); } @@ -74,12 +74,18 @@ export class HashValidator { * @param source - Source name for error messages * @throws Error if hash doesn't match or file cannot be read */ - static async validateOrThrow(filePath: string, expectedHash: string, source?: string): Promise { + static async validateOrThrow( + filePath: string, + expectedHash: string, + source?: string + ): Promise { const isValid = await this.validate(filePath, expectedHash); if (!isValid) { const sourceInfo = source ? ` from ${source}` : ''; - throw new Error(`Hash validation failed${sourceInfo}: File hash does not match expected SHA256`); + throw new Error( + `Hash validation failed${sourceInfo}: File hash does not match expected SHA256` + ); } } @@ -96,7 +102,7 @@ export class HashValidator { return { hash, - size: stats.size + size: stats.size, }; } } diff --git a/backend/src/services/ImageUrlService.ts b/backend/src/services/ImageUrlService.ts index 6a40ebf..ed8e33d 100644 --- a/backend/src/services/ImageUrlService.ts +++ b/backend/src/services/ImageUrlService.ts @@ -20,24 +20,26 @@ export class ImageUrlService { // Construct full image URL from Flashpoint CDN base URL const urls = [ `${baseUrl}/Flashpoint/Data/Images`, - 'https://infinity.unstable.life/Flashpoint/Data/Images' // Secondary fallback + 'https://infinity.unstable.life/Flashpoint/Data/Images', // Secondary fallback ]; logger.info('Using external image URLs from Flashpoint preferences', { baseUrl: preferences.onDemandBaseUrl, - constructedUrls: urls + constructedUrls: urls, }); return urls; } } catch (error) { - logger.warn('Failed to read image URLs from Flashpoint preferences, using defaults', { error }); + logger.warn('Failed to read image URLs from Flashpoint preferences, using defaults', { + error, + }); } // Fallback to hardcoded defaults const defaultUrls = [ 'https://infinity.flashpointarchive.org/Flashpoint/Data/Images', - 'https://infinity.unstable.life/Flashpoint/Data/Images' + 'https://infinity.unstable.life/Flashpoint/Data/Images', ]; logger.info('Using default image URLs', { urls: defaultUrls }); @@ -57,7 +59,7 @@ export class ImageUrlService { return { images: preferences.imageFolderPath || 'Data/Images', logos: preferences.logoFolderPath || 'Data/Logos', - playlists: preferences.playlistFolderPath || 'Data/Playlists' + playlists: preferences.playlistFolderPath || 'Data/Playlists', }; } catch (error) { // Return defaults if preferences unavailable @@ -65,7 +67,7 @@ export class ImageUrlService { return { images: 'Data/Images', logos: 'Data/Logos', - playlists: 'Data/Playlists' + playlists: 'Data/Playlists', }; } } diff --git a/backend/src/services/JobExecutionService.ts b/backend/src/services/JobExecutionService.ts index afd6dcf..d2e604f 100644 --- a/backend/src/services/JobExecutionService.ts +++ b/backend/src/services/JobExecutionService.ts @@ -41,10 +41,9 @@ export class JobExecutionService { const completedAt = new Date().toISOString(); // Calculate duration - const log = UserDatabaseService.get( - 'SELECT started_at FROM job_execution_logs WHERE id = ?', - [id] - ) as { started_at: string } | null; + const log = UserDatabaseService.get('SELECT started_at FROM job_execution_logs WHERE id = ?', [ + id, + ]) as { started_at: string } | null; if (!log) { logger.error(`[JobExecution] Cannot complete: log ${id} not found`); @@ -84,7 +83,7 @@ export class JobExecutionService { return { data: logs.map(this.mapToJobExecutionLog), - total: total.count + total: total.count, }; } @@ -106,7 +105,7 @@ export class JobExecutionService { return { data: logs.map(this.mapToJobExecutionLog), - total: total.count + total: total.count, }; } @@ -133,10 +132,9 @@ export class JobExecutionService { cutoffDate.setDate(cutoffDate.getDate() - retentionDays); const cutoffISO = cutoffDate.toISOString(); - const result = UserDatabaseService.run( - 'DELETE FROM job_execution_logs WHERE started_at < ?', - [cutoffISO] - ); + const result = UserDatabaseService.run('DELETE FROM job_execution_logs WHERE started_at < ?', [ + cutoffISO, + ]); logger.info(`[JobExecution] Cleaned up ${result.changes} old logs`); return result.changes; @@ -153,7 +151,7 @@ export class JobExecutionService { durationSeconds: row.duration_seconds, message: row.message, errorDetails: row.error_details, - triggeredBy: row.triggered_by + triggeredBy: row.triggered_by, }; } } diff --git a/backend/src/services/JobScheduler.ts b/backend/src/services/JobScheduler.ts index f03d943..0f7142d 100644 --- a/backend/src/services/JobScheduler.ts +++ b/backend/src/services/JobScheduler.ts @@ -53,7 +53,9 @@ class JobSchedulerService { // Validate cron expression if (!cron.validate(job.cronSchedule)) { - logger.error(`Cannot start job: Invalid cron expression '${job.cronSchedule}' for job ${job.name}`); + logger.error( + `Cannot start job: Invalid cron expression '${job.cronSchedule}' for job ${job.name}` + ); return; } @@ -189,7 +191,7 @@ class JobSchedulerService { return { enabled: job.enabled, running: this.cronTasks.has(jobId), - cronSchedule: job.cronSchedule + cronSchedule: job.cronSchedule, }; } @@ -229,7 +231,7 @@ class JobSchedulerService { running, cronSchedule: job.cronSchedule, lastExecution, - nextRunEstimate + nextRunEstimate, }; } diff --git a/backend/src/services/MetadataSyncService.ts b/backend/src/services/MetadataSyncService.ts index e9b413c..5bf3935 100644 --- a/backend/src/services/MetadataSyncService.ts +++ b/backend/src/services/MetadataSyncService.ts @@ -7,7 +7,6 @@ import { DatabaseService } from './DatabaseService'; import { GameMetadataSource } from './MetadataUpdateService'; import { SyncStatusService } from './SyncStatusService'; - interface SyncResult { success: boolean; gamesUpdated: number; @@ -57,7 +56,7 @@ interface ApiTag { description: string | null; category: string | null; date_modified: string; - aliases?: string; // semicolon-separated + aliases?: string; // semicolon-separated user_id?: number; Deleted?: boolean; } @@ -67,7 +66,7 @@ interface ApiPlatform { name: string; description: string | null; date_modified: string; - aliases?: string; // semicolon-separated + aliases?: string; // semicolon-separated user_id?: number; Deleted?: boolean; } @@ -124,7 +123,7 @@ export class MetadataSyncService { tagsUpdated: 0, platformsUpdated: 0, error: 'Metadata sync is not available for Flashpoint Ultimate edition', - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }; } const startTime = new Date(); @@ -185,7 +184,7 @@ export class MetadataSyncService { await this.updatePreferencesTimestamps(source, preferences, { gamesLatestDate: latestGameDate, tagsLatestDate: latestTagDate, - platformsLatestDate: latestPlatformDate + platformsLatestDate: latestPlatformDate, }); const result: SyncResult = { @@ -194,7 +193,7 @@ export class MetadataSyncService { gamesDeleted, tagsUpdated, platformsUpdated, - timestamp: startTime.toISOString() + timestamp: startTime.toISOString(), }; // Mark sync as completed @@ -202,7 +201,6 @@ export class MetadataSyncService { logger.info(`[MetadataSync] Sync completed successfully:`, result); return result; - } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; @@ -217,7 +215,7 @@ export class MetadataSyncService { tagsUpdated, platformsUpdated, error: errorMessage, - timestamp: startTime.toISOString() + timestamp: startTime.toISOString(), }; } } @@ -225,7 +223,9 @@ export class MetadataSyncService { /** * Sync platforms from FPFSS API */ - private async syncPlatforms(source: GameMetadataSource): Promise<{ count: number; latestDate: string | null }> { + private async syncPlatforms( + source: GameMetadataSource + ): Promise<{ count: number; latestDate: string | null }> { try { const after = source.platforms?.latestUpdateTime || '1970-01-01'; const url = `${source.baseUrl}/api/platforms?after=${after}`; @@ -261,7 +261,9 @@ export class MetadataSyncService { /** * Sync tags from FPFSS API */ - private async syncTags(source: GameMetadataSource): Promise<{ count: number; latestDate: string | null }> { + private async syncTags( + source: GameMetadataSource + ): Promise<{ count: number; latestDate: string | null }> { try { const after = source.tags?.latestUpdateTime || '1970-01-01'; const url = `${source.baseUrl}/api/tags?after=${after}`; @@ -297,7 +299,9 @@ export class MetadataSyncService { /** * Sync games from FPFSS API (with pagination) */ - private async syncGames(source: GameMetadataSource): Promise<{ updated: number; deleted: number; latestDate: string | null }> { + private async syncGames( + source: GameMetadataSource + ): Promise<{ updated: number; deleted: number; latestDate: string | null }> { try { const after = source.games?.latestUpdateTime || '1970-01-01'; let totalUpdated = 0; @@ -316,8 +320,12 @@ export class MetadataSyncService { } logger.debug(`[MetadataSync] Fetching games batch ${batchCount} from: ${url}`); - const progress = Math.min(80, 40 + (batchCount * 5)); // Cap at 80% during game sync - this.syncStatusService.updateProgress('syncing-games', progress, `Syncing games (batch ${batchCount})...`); + const progress = Math.min(80, 40 + batchCount * 5); // Cap at 80% during game sync + this.syncStatusService.updateProgress( + 'syncing-games', + progress, + `Syncing games (batch ${batchCount})...` + ); const response = await axios.get(url, { timeout: 60000 }); // FPFSS API returns {"games": [...]} not just an array @@ -342,14 +350,19 @@ export class MetadataSyncService { // Get last game ID for pagination afterId = games[games.length - 1].id; - logger.info(`[MetadataSync] Batch ${batchCount}: ${games.length} games processed (total: ${totalUpdated})`); + logger.info( + `[MetadataSync] Batch ${batchCount}: ${games.length} games processed (total: ${totalUpdated})` + ); } // Sync deleted games const deleted = await this.syncDeletedGames(source); - return { updated: totalUpdated, deleted, latestDate: latestDate !== after ? latestDate : null }; - + return { + updated: totalUpdated, + deleted, + latestDate: latestDate !== after ? latestDate : null, + }; } catch (error) { logger.error('[MetadataSync] Error syncing games:', error); throw error; @@ -382,7 +395,9 @@ export class MetadataSyncService { deletedIds = response.data.deletedGames; } else if (response.data.games && Array.isArray(response.data.games)) { // Wrapped in games property - deletedIds = response.data.games.map((g: unknown) => typeof g === 'string' ? g : (g as { id: string }).id); + deletedIds = response.data.games.map((g: unknown) => + typeof g === 'string' ? g : (g as { id: string }).id + ); } else { logger.warn('[MetadataSync] Unexpected deleted games API response format:', response.data); } @@ -396,7 +411,6 @@ export class MetadataSyncService { logger.info(`[MetadataSync] Deleted ${deletedIds.length} games`); return deletedIds.length; - } catch (error) { logger.error('[MetadataSync] Error syncing deleted games:', error); throw error; @@ -420,7 +434,9 @@ export class MetadataSyncService { // Platform data is embedded in game records (game.platformName column) // No separate platform table exists in flashpoint.sqlite // The settings toggle works, but the actual data is synced via game records - logger.debug(`[MetadataSync] Received ${platforms.length} platform updates (data synced via game records)`); + logger.debug( + `[MetadataSync] Received ${platforms.length} platform updates (data synced via game records)` + ); } /** @@ -440,7 +456,9 @@ export class MetadataSyncService { // Tag data is embedded in game records (game.tagsStr column as semicolon-separated strings) // No separate tag table exists in flashpoint.sqlite // The settings toggle works, but the actual data is synced via game records - logger.debug(`[MetadataSync] Received ${tags.length} tag updates (data synced via game records)`); + logger.debug( + `[MetadataSync] Received ${tags.length} tag updates (data synced via game records)` + ); } /** @@ -454,12 +472,15 @@ export class MetadataSyncService { const sortedGames = this.sortGamesByParentDependency(games); // Build SQL for bulk upsert (29 columns per row) - const placeholders = sortedGames.map(() => - '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' - ).join(', '); + const placeholders = sortedGames + .map( + () => + '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + ) + .join(', '); const values: any[] = []; - sortedGames.forEach(game => { + sortedGames.forEach((game) => { values.push( game.id, game.parent_game_id || null, @@ -547,7 +568,7 @@ export class MetadataSyncService { const visiting = new Set(); // Track games currently being visited to detect cycles // Build game map - games.forEach(game => gameMap.set(game.id, game)); + games.forEach((game) => gameMap.set(game.id, game)); // Recursive function to add game and its parents first const addGame = (game: ApiGame): void => { @@ -556,7 +577,9 @@ export class MetadataSyncService { // Circular dependency detected - break the cycle by skipping parent if (visiting.has(game.id)) { - logger.warn(`[MetadataSync] Circular parent dependency detected for game: ${game.id} (${game.title})`); + logger.warn( + `[MetadataSync] Circular parent dependency detected for game: ${game.id} (${game.title})` + ); visited.add(game.id); sorted.push(game); return; @@ -578,7 +601,7 @@ export class MetadataSyncService { }; // Process all games - games.forEach(game => addGame(game)); + games.forEach((game) => addGame(game)); return sorted; } @@ -621,7 +644,9 @@ export class MetadataSyncService { const updateSql = `UPDATE game SET parentGameId = NULL WHERE id IN (${updatePlaceholders})`; const updateStmt = db.prepare(updateSql); updateStmt.run(childGameIds); - logger.debug(`[MetadataSync] Orphaned ${childGameIds.length} child games (set parentGameId to NULL)`); + logger.debug( + `[MetadataSync] Orphaned ${childGameIds.length} child games (set parentGameId to NULL)` + ); } // Now safe to delete the games @@ -667,25 +692,33 @@ export class MetadataSyncService { // Update latestUpdateTime to the latest game/tag/platform dateModified // This is what the update check uses to determine what's new if (latestDates.gamesLatestDate) { - preferences.gameMetadataSources[sourceIndex].games.latestUpdateTime = latestDates.gamesLatestDate; - logger.info(`[MetadataSync] Updated games latestUpdateTime to: ${latestDates.gamesLatestDate}`); + preferences.gameMetadataSources[sourceIndex].games.latestUpdateTime = + latestDates.gamesLatestDate; + logger.info( + `[MetadataSync] Updated games latestUpdateTime to: ${latestDates.gamesLatestDate}` + ); } if (latestDates.tagsLatestDate) { - preferences.gameMetadataSources[sourceIndex].tags.latestUpdateTime = latestDates.tagsLatestDate; - logger.info(`[MetadataSync] Updated tags latestUpdateTime to: ${latestDates.tagsLatestDate}`); + preferences.gameMetadataSources[sourceIndex].tags.latestUpdateTime = + latestDates.tagsLatestDate; + logger.info( + `[MetadataSync] Updated tags latestUpdateTime to: ${latestDates.tagsLatestDate}` + ); } - if (latestDates.platformsLatestDate && preferences.gameMetadataSources[sourceIndex].platforms) { - preferences.gameMetadataSources[sourceIndex].platforms.latestUpdateTime = latestDates.platformsLatestDate; - logger.info(`[MetadataSync] Updated platforms latestUpdateTime to: ${latestDates.platformsLatestDate}`); + if ( + latestDates.platformsLatestDate && + preferences.gameMetadataSources[sourceIndex].platforms + ) { + preferences.gameMetadataSources[sourceIndex].platforms.latestUpdateTime = + latestDates.platformsLatestDate; + logger.info( + `[MetadataSync] Updated platforms latestUpdateTime to: ${latestDates.platformsLatestDate}` + ); } } // Write back to preferences.json - await fs.writeFile( - this.preferencesPath, - JSON.stringify(preferences, null, 2), - 'utf-8' - ); + await fs.writeFile(this.preferencesPath, JSON.stringify(preferences, null, 2), 'utf-8'); logger.info(`[MetadataSync] Updated preferences.json timestamps. actualUpdateTime: ${now}`); } catch (error) { @@ -693,5 +726,4 @@ export class MetadataSyncService { throw error; } } - } diff --git a/backend/src/services/MetadataUpdateService.ts b/backend/src/services/MetadataUpdateService.ts index 8b8567c..e0a2241 100644 --- a/backend/src/services/MetadataUpdateService.ts +++ b/backend/src/services/MetadataUpdateService.ts @@ -4,7 +4,6 @@ import axios from 'axios'; import { config } from '../config'; import { logger } from '../utils/logger'; - export interface GameMetadataSource { name: string; baseUrl: string; @@ -63,7 +62,7 @@ export class MetadataUpdateService { return { hasUpdates: false, gamesUpdateAvailable: false, - tagsUpdateAvailable: false + tagsUpdateAvailable: false, }; } // Read preferences file @@ -78,7 +77,7 @@ export class MetadataUpdateService { return { hasUpdates: false, gamesUpdateAvailable: false, - tagsUpdateAvailable: false + tagsUpdateAvailable: false, }; } @@ -98,7 +97,9 @@ export class MetadataUpdateService { const hasUpdates = gamesUpdateAvailable || tagsUpdateAvailable; logger.info(`[MetadataUpdate] Games: ${gamesUpdateCount} updates available`); - logger.info(`[MetadataUpdate] Tags: ${tagsUpdateCount} updates available (synced via game records)`); + logger.info( + `[MetadataUpdate] Tags: ${tagsUpdateCount} updates available (synced via game records)` + ); return { hasUpdates, @@ -108,7 +109,7 @@ export class MetadataUpdateService { tagsUpdateCount, lastCheckedTime: source.games.actualUpdateTime, lastUpdateTime: source.games.latestUpdateTime, - source + source, }; } catch (error) { logger.error('[MetadataUpdate] Failed to read metadata update info:', error); @@ -117,7 +118,7 @@ export class MetadataUpdateService { return { hasUpdates: false, gamesUpdateAvailable: false, - tagsUpdateAvailable: false + tagsUpdateAvailable: false, }; } } @@ -127,7 +128,10 @@ export class MetadataUpdateService { * Queries: ${baseUrl}/api/games/updates?after=${timestamp} * Returns the actual count of updates from the server */ - private async getUpdateCount(source: GameMetadataSource, type: 'games' | 'tags'): Promise { + private async getUpdateCount( + source: GameMetadataSource, + type: 'games' | 'tags' + ): Promise { try { // Add 2 seconds to update time to prevent rounding down errors (like Launcher does) const d = new Date(source[type].latestUpdateTime); @@ -138,7 +142,7 @@ export class MetadataUpdateService { logger.debug(`[MetadataUpdate] Querying ${type} updates: ${countUrl}`); const response = await axios.get(countUrl, { - timeout: 10000 + timeout: 10000, }); if (response.data && typeof response.data.total === 'number') { @@ -148,7 +152,10 @@ export class MetadataUpdateService { logger.warn(`[MetadataUpdate] Invalid response from ${countUrl}`); return 0; } catch (error) { - logger.error(`[MetadataUpdate] Error fetching ${type} update count:`, error instanceof Error ? error.message : 'Unknown error'); + logger.error( + `[MetadataUpdate] Error fetching ${type} update count:`, + error instanceof Error ? error.message : 'Unknown error' + ); return -1; // Return -1 on error (like Launcher does) } } @@ -176,18 +183,21 @@ export class MetadataUpdateService { // Query FPFSS API for latest metadata info const response = await axios.get(fpfssUrl, { timeout: 10000, - validateStatus: (status) => status === 200 + validateStatus: (status) => status === 200, }); if (response.data && response.data.games && response.data.games.latestUpdateTime) { return { - latestUpdateTime: response.data.games.latestUpdateTime + latestUpdateTime: response.data.games.latestUpdateTime, }; } return null; } catch (error) { - logger.warn('[MetadataUpdate] Failed to check remote updates:', error instanceof Error ? error.message : 'Unknown error'); + logger.warn( + '[MetadataUpdate] Failed to check remote updates:', + error instanceof Error ? error.message : 'Unknown error' + ); return null; } } @@ -214,7 +224,7 @@ export class MetadataUpdateService { month: 'short', day: 'numeric', hour: '2-digit', - minute: '2-digit' + minute: '2-digit', }); } } diff --git a/backend/src/services/PerformanceMetrics.ts b/backend/src/services/PerformanceMetrics.ts index b8706a2..e5bc5d5 100644 --- a/backend/src/services/PerformanceMetrics.ts +++ b/backend/src/services/PerformanceMetrics.ts @@ -55,7 +55,7 @@ export class PerformanceMetrics { const metrics = this.endpointMetrics.get(key)!; metrics.push({ timestamp: Date.now(), - value: durationMs + value: durationMs, }); // Trim old data points @@ -67,7 +67,7 @@ export class PerformanceMetrics { if (durationMs > 1000) { logger.warn('[PerformanceMetrics] Slow endpoint detected', { endpoint: key, - duration: `${durationMs}ms` + duration: `${durationMs}ms`, }); } } @@ -100,7 +100,7 @@ export class PerformanceMetrics { static recordQuery(durationMs: number): void { this.queryMetrics.push({ timestamp: Date.now(), - value: durationMs + value: durationMs, }); // Trim old data points @@ -151,7 +151,7 @@ export class PerformanceMetrics { stats[cacheName] = { hits: metrics.hits, misses: metrics.misses, - hitRate: Math.round(hitRate * 100) / 100 // Round to 2 decimal places + hitRate: Math.round(hitRate * 100) / 100, // Round to 2 decimal places }; } @@ -178,7 +178,7 @@ export class PerformanceMetrics { return Object.entries(endpointStats) .map(([endpoint, stats]) => ({ endpoint, - avgDuration: Math.round(stats.avg) + avgDuration: Math.round(stats.avg), })) .sort((a, b) => b.avgDuration - a.avgDuration) .slice(0, limit); @@ -188,7 +188,7 @@ export class PerformanceMetrics { * Calculate statistics from metric points */ private static calculateStats(points: MetricPoint[]): MetricStats { - const values = points.map(p => p.value).sort((a, b) => a - b); + const values = points.map((p) => p.value).sort((a, b) => a - b); const count = values.length; const sum = values.reduce((acc, val) => acc + val, 0); @@ -200,7 +200,7 @@ export class PerformanceMetrics { avg: sum / count, p50: this.percentile(values, 0.5), p95: this.percentile(values, 0.95), - p99: this.percentile(values, 0.99) + p99: this.percentile(values, 0.99), }; } @@ -220,7 +220,7 @@ export class PerformanceMetrics { // Cleanup endpoint metrics for (const [key, metrics] of this.endpointMetrics.entries()) { - const filtered = metrics.filter(m => m.timestamp > cutoff); + const filtered = metrics.filter((m) => m.timestamp > cutoff); if (filtered.length === 0) { this.endpointMetrics.delete(key); @@ -230,7 +230,7 @@ export class PerformanceMetrics { } // Cleanup query metrics - this.queryMetrics = this.queryMetrics.filter(m => m.timestamp > cutoff); + this.queryMetrics = this.queryMetrics.filter((m) => m.timestamp > cutoff); logger.debug('[PerformanceMetrics] Cleaned up old metrics'); } @@ -256,10 +256,10 @@ export class PerformanceMetrics { return { endpoints: { total: this.endpointMetrics.size, - slowest: this.getSlowestEndpoints(5) + slowest: this.getSlowestEndpoints(5), }, caches: this.getCacheStats(), - queries: this.getQueryStats() + queries: this.getQueryStats(), }; } } diff --git a/backend/src/services/PermissionCache.ts b/backend/src/services/PermissionCache.ts index ab22298..29638e7 100644 --- a/backend/src/services/PermissionCache.ts +++ b/backend/src/services/PermissionCache.ts @@ -34,9 +34,12 @@ export class PermissionCache { if (this.cleanupInterval) return; // Run cleanup every 5 minutes - this.cleanupInterval = setInterval(() => { - this.cleanup(); - }, 5 * 60 * 1000); + this.cleanupInterval = setInterval( + () => { + this.cleanup(); + }, + 5 * 60 * 1000 + ); logger.info('[PermissionCache] Cleanup interval started'); } @@ -79,10 +82,12 @@ export class PermissionCache { static setUserPermissions(userId: number, permissions: string[]): void { this.userPermissionsCache.set(userId, { data: permissions, - expiresAt: Date.now() + this.USER_PERMISSIONS_TTL + expiresAt: Date.now() + this.USER_PERMISSIONS_TTL, }); - logger.debug(`[PermissionCache] Cached permissions for user ${userId} (${permissions.length} permissions, TTL: 5min)`); + logger.debug( + `[PermissionCache] Cached permissions for user ${userId} (${permissions.length} permissions, TTL: 5min)` + ); } /** @@ -112,10 +117,12 @@ export class PermissionCache { static setRolePermissions(roleId: number, permissions: string[]): void { this.rolePermissionsCache.set(roleId, { data: permissions, - expiresAt: Date.now() + this.ROLE_PERMISSIONS_TTL + expiresAt: Date.now() + this.ROLE_PERMISSIONS_TTL, }); - logger.debug(`[PermissionCache] Cached permissions for role ${roleId} (${permissions.length} permissions, TTL: 10min)`); + logger.debug( + `[PermissionCache] Cached permissions for role ${roleId} (${permissions.length} permissions, TTL: 10min)` + ); } /** @@ -196,7 +203,9 @@ export class PermissionCache { } if (userExpired > 0 || roleExpired > 0) { - logger.debug(`[PermissionCache] Cleanup: removed ${userExpired} user entries, ${roleExpired} role entries`); + logger.debug( + `[PermissionCache] Cleanup: removed ${userExpired} user entries, ${roleExpired} role entries` + ); } } @@ -211,7 +220,7 @@ export class PermissionCache { return { userCacheSize: this.userPermissionsCache.size, roleCacheSize: this.rolePermissionsCache.size, - totalSize: this.userPermissionsCache.size + this.rolePermissionsCache.size + totalSize: this.userPermissionsCache.size + this.rolePermissionsCache.size, }; } } diff --git a/backend/src/services/PlayTrackingService.ts b/backend/src/services/PlayTrackingService.ts index 5ccf1fa..0a758c6 100644 --- a/backend/src/services/PlayTrackingService.ts +++ b/backend/src/services/PlayTrackingService.ts @@ -2,7 +2,12 @@ import { UserDatabaseService } from './UserDatabaseService'; import { DatabaseService } from './DatabaseService'; import { logger } from '../utils/logger'; import { randomBytes } from 'crypto'; -import { PlaySessionRow, GameStatsRow, PlaytimeActivityRow, DistributionRow } from '../types/database-rows'; +import { + PlaySessionRow, + GameStatsRow, + PlaytimeActivityRow, + DistributionRow, +} from '../types/database-rows'; export interface PlaySession { id: number; @@ -49,7 +54,9 @@ export class PlayTrackingService { [userId, gameId, gameTitle, sessionId] ); - logger.info(`Play session started: userId=${userId}, gameId=${gameId}, sessionId=${sessionId}`); + logger.info( + `Play session started: userId=${userId}, gameId=${gameId}, sessionId=${sessionId}` + ); return sessionId; } catch (error) { logger.error('Failed to start play session:', error); @@ -92,7 +99,12 @@ export class PlayTrackingService { ); // Update aggregated stats - await this.updateGameStats(session.user_id, session.game_id, session.game_title, durationSeconds); + await this.updateGameStats( + session.user_id, + session.game_id, + session.game_title, + durationSeconds + ); await this.updateUserStats(session.user_id, durationSeconds); // Update aggregate stats in Flashpoint database for compatibility with Launcher @@ -150,10 +162,9 @@ export class PlayTrackingService { private async updateUserStats(userId: number, durationSeconds: number): Promise { try { // Check if stats exist - const existing = UserDatabaseService.get( - 'SELECT * FROM user_stats WHERE user_id = ?', - [userId] - ); + const existing = UserDatabaseService.get('SELECT * FROM user_stats WHERE user_id = ?', [ + userId, + ]); // Count unique games played const gamesPlayed = UserDatabaseService.get( @@ -221,12 +232,7 @@ export class PlayTrackingService { playtime = ?, playCounter = ? WHERE id = ?`, - [ - aggregateStats.lastPlayed, - aggregateStats.totalPlaytime, - aggregateStats.totalPlays, - gameId - ] + [aggregateStats.lastPlayed, aggregateStats.totalPlaytime, aggregateStats.totalPlays, gameId] ); logger.info( @@ -256,7 +262,7 @@ export class PlayTrackingService { totalPlaytimeSeconds: 0, totalSessions: 0, firstPlayAt: null, - lastPlayAt: null + lastPlayAt: null, }; } @@ -266,7 +272,7 @@ export class PlayTrackingService { totalPlaytimeSeconds: stats.total_playtime_seconds, totalSessions: stats.total_sessions, firstPlayAt: stats.first_play_at, - lastPlayAt: stats.last_play_at + lastPlayAt: stats.last_play_at, }; } catch (error) { logger.error('Failed to get user stats:', error); @@ -294,7 +300,7 @@ export class PlayTrackingService { totalPlays: stat.total_plays, totalPlaytimeSeconds: stat.total_playtime_seconds, firstPlayedAt: stat.first_played_at, - lastPlayedAt: stat.last_played_at + lastPlayedAt: stat.last_played_at, })); } catch (error) { logger.error('Failed to get user game stats:', error); @@ -324,7 +330,7 @@ export class PlayTrackingService { startedAt: session.started_at, endedAt: session.ended_at, durationSeconds: session.duration_seconds, - sessionId: session.session_id + sessionId: session.session_id, })); } catch (error) { logger.error('Failed to get user play history:', error); @@ -352,7 +358,7 @@ export class PlayTrackingService { totalPlays: stat.total_plays, totalPlaytimeSeconds: stat.total_playtime_seconds, firstPlayedAt: stat.first_played_at, - lastPlayedAt: stat.last_played_at + lastPlayedAt: stat.last_played_at, })); } catch (error) { logger.error('Failed to get top games:', error); @@ -363,7 +369,10 @@ export class PlayTrackingService { /** * Get play activity over time (daily aggregation) */ - async getPlayActivityOverTime(userId: number, days = 30): Promise> { + async getPlayActivityOverTime( + userId: number, + days = 30 + ): Promise> { try { // Validate and sanitize days parameter to prevent SQL injection // Ensure it's a positive integer between 1 and 365 @@ -386,7 +395,7 @@ export class PlayTrackingService { return data.map((row: PlaytimeActivityRow) => ({ date: row.date, playtime: row.total_playtime || 0, - sessions: row.session_count || 0 + sessions: row.session_count || 0, })); } catch (error) { logger.error('Failed to get play activity over time:', error); @@ -397,7 +406,10 @@ export class PlayTrackingService { /** * Get games distribution by playtime */ - async getGamesDistribution(userId: number, limit = 10): Promise> { + async getGamesDistribution( + userId: number, + limit = 10 + ): Promise> { try { const stats = UserDatabaseService.all( `SELECT game_title, total_playtime_seconds @@ -410,7 +422,7 @@ export class PlayTrackingService { return stats.map((stat: DistributionRow) => ({ name: stat.game_title, - value: stat.total_playtime_seconds + value: stat.total_playtime_seconds, })); } catch (error) { logger.error('Failed to get games distribution:', error); diff --git a/backend/src/services/PlaylistService.ts b/backend/src/services/PlaylistService.ts index acec952..e3147c9 100644 --- a/backend/src/services/PlaylistService.ts +++ b/backend/src/services/PlaylistService.ts @@ -45,7 +45,11 @@ export class PlaylistService { // Extract game IDs from playlist (they're objects with gameId property) const gameIds = Array.isArray(playlist.games) - ? playlist.games.map((g: unknown) => typeof g === 'string' ? g : (g as {gameId?: string}).gameId).filter(Boolean) + ? playlist.games + .map((g: unknown) => + typeof g === 'string' ? g : (g as { gameId?: string }).gameId + ) + .filter(Boolean) : []; playlists.push({ @@ -55,7 +59,7 @@ export class PlaylistService { author: playlist.author, library: playlist.library, icon: playlist.icon, - gameIds + gameIds, }); } catch (error) { logger.warn(`Failed to parse playlist file: ${file}`, error); @@ -91,7 +95,8 @@ export class PlaylistService { if (playlist.games && Array.isArray(playlist.games)) { for (const playlistGame of playlist.games) { // Playlist games are objects with gameId property, not just strings - const gameId = typeof playlistGame === 'string' ? playlistGame : playlistGame.gameId; + const gameId = + typeof playlistGame === 'string' ? playlistGame : playlistGame.gameId; if (gameId) { const game = await this.gameService.getGameById(gameId); @@ -109,7 +114,7 @@ export class PlaylistService { author: playlist.author, library: playlist.library, icon: playlist.icon, - games + games, }; } } @@ -135,7 +140,7 @@ export class PlaylistService { description: data.description || '', author: data.author || 'Unknown', library: data.library || 'arcade', - games: [] + games: [], }; // Write playlist file @@ -151,7 +156,10 @@ export class PlaylistService { } } - async addGamesToPlaylist(playlistId: string, data: AddGamesToPlaylistDto): Promise { + async addGamesToPlaylist( + playlistId: string, + data: AddGamesToPlaylistDto + ): Promise { try { const playlistsPath = config.flashpointPlaylistsPath; const files = await fs.readdir(playlistsPath); @@ -168,14 +176,14 @@ export class PlaylistService { if (id === playlistId) { // Get existing game IDs const existingGameIds = Array.isArray(playlist.games) - ? playlist.games.map((g: any) => typeof g === 'string' ? g : g.gameId) + ? playlist.games.map((g: any) => (typeof g === 'string' ? g : g.gameId)) : []; // Add new game IDs (avoid duplicates) - const newGameIds = data.gameIds.filter(gameId => !existingGameIds.includes(gameId)); + const newGameIds = data.gameIds.filter((gameId) => !existingGameIds.includes(gameId)); // Convert to playlist game format - const newGames = newGameIds.map(gameId => ({ gameId })); + const newGames = newGameIds.map((gameId) => ({ gameId })); playlist.games = [...(playlist.games || []), ...newGames]; @@ -197,7 +205,10 @@ export class PlaylistService { } } - async removeGamesFromPlaylist(playlistId: string, data: AddGamesToPlaylistDto): Promise { + async removeGamesFromPlaylist( + playlistId: string, + data: AddGamesToPlaylistDto + ): Promise { try { const playlistsPath = config.flashpointPlaylistsPath; const files = await fs.readdir(playlistsPath); @@ -266,8 +277,8 @@ export class PlaylistService { private generateUUID(): string { // Simple UUID v4 generator return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { - const r = Math.random() * 16 | 0; - const v = c === 'x' ? r : (r & 0x3 | 0x8); + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); } diff --git a/backend/src/services/PreferencesService.ts b/backend/src/services/PreferencesService.ts index a23b005..a0c2051 100644 --- a/backend/src/services/PreferencesService.ts +++ b/backend/src/services/PreferencesService.ts @@ -41,7 +41,7 @@ export class PreferencesService { const now = Date.now(); // Return cached preferences if still valid - if (this.preferences && (now - this.lastLoadTime) < this.CACHE_TTL) { + if (this.preferences && now - this.lastLoadTime < this.CACHE_TTL) { return this.preferences; } @@ -78,11 +78,13 @@ export class PreferencesService { sourceCount: parsed.gameDataSources?.length || 0, dataPacksPath: parsed.dataPacksFolderPath, imageFolderPath: parsed.imageFolderPath, - onDemandBaseUrl: parsed.onDemandBaseUrl + onDemandBaseUrl: parsed.onDemandBaseUrl, }); } catch (error) { logger.error('Failed to load preferences:', error); - throw new Error(`Failed to load Flashpoint preferences: ${error instanceof Error ? error.message : 'Unknown error'}`); + throw new Error( + `Failed to load Flashpoint preferences: ${error instanceof Error ? error.message : 'Unknown error'}` + ); } } diff --git a/backend/src/services/RoleService.ts b/backend/src/services/RoleService.ts index 16e094e..1641c2d 100644 --- a/backend/src/services/RoleService.ts +++ b/backend/src/services/RoleService.ts @@ -18,9 +18,9 @@ export class RoleService { ); // Get permissions for each role - return roles.map(role => ({ + return roles.map((role) => ({ ...role, - permissions: this.getRolePermissions(role.id) + permissions: this.getRolePermissions(role.id), })); } @@ -38,7 +38,7 @@ export class RoleService { return { ...role, - permissions: this.getRolePermissions(id) + permissions: this.getRolePermissions(id), }; } @@ -100,7 +100,12 @@ export class RoleService { /** * Update role */ - async updateRole(id: number, name?: string, description?: string, priority?: number): Promise { + async updateRole( + id: number, + name?: string, + description?: string, + priority?: number + ): Promise { const role = await this.getRoleById(id); if (!role) { throw new AppError(404, 'Role not found'); @@ -115,10 +120,10 @@ export class RoleService { const params: any[] = []; if (name !== undefined) { - const existing = UserDatabaseService.get( - 'SELECT id FROM roles WHERE name = ? AND id != ?', - [name, id] - ); + const existing = UserDatabaseService.get('SELECT id FROM roles WHERE name = ? AND id != ?', [ + name, + id, + ]); if (existing) { throw new AppError(409, 'Role name already exists'); } @@ -140,14 +145,11 @@ export class RoleService { return role; } - updates.push("updated_at = ?"); + updates.push('updated_at = ?'); params.push(new Date().toISOString()); params.push(id); - UserDatabaseService.run( - `UPDATE roles SET ${updates.join(', ')} WHERE id = ?`, - params - ); + UserDatabaseService.run(`UPDATE roles SET ${updates.join(', ')} WHERE id = ?`, params); return (await this.getRoleById(id))!; } @@ -201,10 +203,9 @@ export class RoleService { } // Check if role is assigned to users - const userCount = UserDatabaseService.get( - 'SELECT COUNT(*) as count FROM users WHERE role_id = ?', - [id] - )?.count || 0; + const userCount = + UserDatabaseService.get('SELECT COUNT(*) as count FROM users WHERE role_id = ?', [id]) + ?.count || 0; if (userCount > 0) { throw new AppError(409, `Cannot delete role: ${userCount} users are assigned to this role`); diff --git a/backend/src/services/RuffleService.ts b/backend/src/services/RuffleService.ts index 9ba60c6..a115814 100644 --- a/backend/src/services/RuffleService.ts +++ b/backend/src/services/RuffleService.ts @@ -73,9 +73,9 @@ export class RuffleService { const response = await axios.get(`${this.githubApiUrl}?per_page=1`, { timeout: 10000, headers: { - 'Accept': 'application/vnd.github.v3+json', - 'User-Agent': 'Flashpoint-Web' - } + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'Flashpoint-Web', + }, }); const release = response.data[0]; @@ -84,9 +84,7 @@ export class RuffleService { } // Find web-selfhosted asset - const asset = release.assets.find((a: any) => - a.name.includes('web-selfhosted.zip') - ); + const asset = release.assets.find((a: any) => a.name.includes('web-selfhosted.zip')); if (!asset) { throw new Error('web-selfhosted.zip not found in release'); @@ -96,7 +94,7 @@ export class RuffleService { version: release.tag_name.replace('nightly-', ''), downloadUrl: asset.browser_download_url, publishedAt: release.published_at, - changelog: release.body || 'No changelog available.' + changelog: release.body || 'No changelog available.', }; } catch (error) { logger.error('Error fetching latest Ruffle version:', error); @@ -128,7 +126,7 @@ export class RuffleService { latestVersion: latest.version, updateAvailable, changelog: latest.changelog, - publishedAt: latest.publishedAt + publishedAt: latest.publishedAt, }; } @@ -149,8 +147,8 @@ export class RuffleService { responseType: 'arraybuffer', timeout: 60000, // 60 seconds for download headers: { - 'User-Agent': 'Flashpoint-Web' - } + 'User-Agent': 'Flashpoint-Web', + }, }); logger.info('[RuffleService] Download complete, extracting...'); @@ -198,7 +196,7 @@ export class RuffleService { return { success: true, version: latest.version, - message: `Successfully updated Ruffle to version ${latest.version}` + message: `Successfully updated Ruffle to version ${latest.version}`, }; } catch (error) { logger.error('[RuffleService] Error updating Ruffle:', error); diff --git a/backend/src/services/StatisticsService.ts b/backend/src/services/StatisticsService.ts index 2ec170e..5787c9c 100644 --- a/backend/src/services/StatisticsService.ts +++ b/backend/src/services/StatisticsService.ts @@ -15,7 +15,7 @@ export class StatisticsService { private playlistService = new PlaylistService(); private cache: { data: Statistics | null; timestamp: number } = { data: null, - timestamp: 0 + timestamp: 0, }; private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes @@ -73,7 +73,10 @@ export class StatisticsService { const uniqueTags = new Set(); for (const row of results) { - const tags = row.tagsStr.split(';').map(tag => tag.trim()).filter(tag => tag); + const tags = row.tagsStr + .split(';') + .map((tag) => tag.trim()) + .filter((tag) => tag); for (const tag of tags) { uniqueTags.add(tag); } @@ -103,7 +106,7 @@ export class StatisticsService { totalPlatforms, webPlayableGames, totalPlaylists, - totalTags + totalTags, }; logger.debug('Statistics calculated:', statistics); @@ -111,7 +114,7 @@ export class StatisticsService { // Update cache this.cache = { data: statistics, - timestamp: Date.now() + timestamp: Date.now(), }; return statistics; diff --git a/backend/src/services/SyncStatusService.ts b/backend/src/services/SyncStatusService.ts index 7200d71..6de6ddd 100644 --- a/backend/src/services/SyncStatusService.ts +++ b/backend/src/services/SyncStatusService.ts @@ -31,7 +31,7 @@ export class SyncStatusService { isRunning: false, stage: 'idle', progress: 0, - message: 'No sync in progress' + message: 'No sync in progress', }; } @@ -69,7 +69,7 @@ export class SyncStatusService { stage: 'initializing', progress: 0, message: 'Starting metadata sync...', - startTime: new Date().toISOString() + startTime: new Date().toISOString(), }; logger.info('[SyncStatus] Sync started'); @@ -121,7 +121,7 @@ export class SyncStatusService { isRunning: false, stage: 'idle', progress: 0, - message: 'No sync in progress' + message: 'No sync in progress', }; logger.debug('[SyncStatus] Status reset'); diff --git a/backend/src/services/SystemSettingsService.ts b/backend/src/services/SystemSettingsService.ts index 7371a56..8bd5d72 100644 --- a/backend/src/services/SystemSettingsService.ts +++ b/backend/src/services/SystemSettingsService.ts @@ -137,10 +137,7 @@ export class SystemSettingsService { * Check if a setting exists */ exists(key: string): boolean { - const row = UserDatabaseService.get( - 'SELECT 1 FROM system_settings WHERE key = ?', - [key] - ); + const row = UserDatabaseService.get('SELECT 1 FROM system_settings WHERE key = ?', [key]); return !!row; } @@ -182,7 +179,7 @@ export class SystemSettingsService { category: row.category, description: row.description, isPublic: row.is_public === 1, - defaultValue: this.parseValue(row.default_value, row.data_type) + defaultValue: this.parseValue(row.default_value, row.data_type), }; } @@ -203,21 +200,27 @@ export class SystemSettingsService { const schema = JSON.parse(row.validation_schema); this.validateAgainstSchema(value, schema, key); } catch (error) { - throw new Error(`Invalid validation schema for ${key}: ${error instanceof Error ? error.message : 'Unknown error'}`); + throw new Error( + `Invalid validation schema for ${key}: ${error instanceof Error ? error.message : 'Unknown error'}` + ); } } /** * Validate value against JSON schema */ - private validateAgainstSchema(value: SettingValue, schema: Record, key: string): void { + private validateAgainstSchema( + value: SettingValue, + schema: Record, + key: string + ): void { // Type validation if (typeof schema.type === 'string') { const actualType = this.getType(value); // Allow integers when schema expects 'number' (e.g., 0, 1 are valid for float settings) - const isValidType = actualType === schema.type || - (schema.type === 'number' && actualType === 'integer'); + const isValidType = + actualType === schema.type || (schema.type === 'number' && actualType === 'integer'); if (!isValidType) { throw new Error(`Setting '${key}' must be of type ${schema.type}, got ${actualType}`); diff --git a/backend/src/services/UpdateService.ts b/backend/src/services/UpdateService.ts index 8dec57a..767da78 100644 --- a/backend/src/services/UpdateService.ts +++ b/backend/src/services/UpdateService.ts @@ -45,7 +45,7 @@ export class UpdateService { { id: 'core-manager', name: 'Manager' }, { id: 'core-database', name: 'Database' }, { id: 'core-server-legacy', name: 'Legacy Server' }, - { id: 'core-configuration', name: 'Configuration' } + { id: 'core-configuration', name: 'Configuration' }, ]; // Read components directory to check installed versions @@ -58,7 +58,10 @@ export class UpdateService { const componentPath = path.join(componentsDir, component.id); // Check if component file exists - const exists = await fs.access(componentPath).then(() => true).catch(() => false); + const exists = await fs + .access(componentPath) + .then(() => true) + .catch(() => false); if (exists) { // Read component file - first line contains hash, size, and dependencies @@ -76,14 +79,14 @@ export class UpdateService { id: component.id, name: component.name, state: 'UP_TO_DATE', - currentVersion + currentVersion, }); } else { logger.warn(`[UpdateService] Component file not found: ${componentPath}`); componentsStatus.push({ id: component.id, name: component.name, - state: 'ERROR' + state: 'ERROR', }); } } catch (error) { @@ -91,7 +94,7 @@ export class UpdateService { componentsStatus.push({ id: component.id, name: component.name, - state: 'ERROR' + state: 'ERROR', }); } } @@ -104,7 +107,6 @@ export class UpdateService { } catch (error) { logger.warn('[UpdateService] Could not fetch remote component list:', error); } - } catch (error) { logger.error('[UpdateService] Error during update check:', error); } @@ -136,7 +138,7 @@ export class UpdateService { version, buildDate, componentsStatus, - lastChecked: new Date().toISOString() + lastChecked: new Date().toISOString(), }; } @@ -145,13 +147,16 @@ export class UpdateService { try { // Check if Flashpoint Manager exists - const managerExists = await fs.access(this.managerPath).then(() => true).catch(() => false); + const managerExists = await fs + .access(this.managerPath) + .then(() => true) + .catch(() => false); if (!managerExists) { logger.warn('[UpdateService] Flashpoint Manager not found at:', this.managerPath); return { success: false, - message: 'Flashpoint Manager not found. Please update manually.' + message: 'Flashpoint Manager not found. Please update manually.', }; } @@ -159,7 +164,7 @@ export class UpdateService { return new Promise((resolve, reject) => { const fpm = spawn(this.managerPath, ['update'], { cwd: path.join(this.flashpointRoot, 'Manager'), - windowsHide: true + windowsHide: true, }); let errorOutput = ''; @@ -178,13 +183,13 @@ export class UpdateService { logger.info('[UpdateService] Updates installed successfully'); resolve({ success: true, - message: 'Updates installed successfully. Please restart the application.' + message: 'Updates installed successfully. Please restart the application.', }); } else { logger.error('[UpdateService] Update failed with code:', code); resolve({ success: false, - message: `Update failed: ${errorOutput || 'Unknown error'}` + message: `Update failed: ${errorOutput || 'Unknown error'}`, }); } }); @@ -195,28 +200,37 @@ export class UpdateService { }); // Timeout after 5 minutes - setTimeout(() => { - fpm.kill(); - reject(new Error('Update process timed out')); - }, 5 * 60 * 1000); + setTimeout( + () => { + fpm.kill(); + reject(new Error('Update process timed out')); + }, + 5 * 60 * 1000 + ); }); - } catch (error) { logger.error('[UpdateService] Error during update installation:', error); return { success: false, - message: error instanceof Error ? error.message : 'Failed to install updates' + message: error instanceof Error ? error.message : 'Failed to install updates', }; } } - async getSystemInfo(): Promise<{ flashpointRoot: string; managerPath: string; managerExists: boolean }> { - const managerExists = await fs.access(this.managerPath).then(() => true).catch(() => false); + async getSystemInfo(): Promise<{ + flashpointRoot: string; + managerPath: string; + managerExists: boolean; + }> { + const managerExists = await fs + .access(this.managerPath) + .then(() => true) + .catch(() => false); return { flashpointRoot: this.flashpointRoot, managerPath: this.managerPath, - managerExists + managerExists, }; } } diff --git a/backend/src/services/UserDatabaseService.ts b/backend/src/services/UserDatabaseService.ts index 4608f8e..12069ad 100644 --- a/backend/src/services/UserDatabaseService.ts +++ b/backend/src/services/UserDatabaseService.ts @@ -93,7 +93,7 @@ export class UserDatabaseService { ).all() as { name: string }[]; // Filter out the migrations table itself to check for other tables - const otherTables = tables.filter(t => t.name !== 'migrations'); + const otherTables = tables.filter((t) => t.name !== 'migrations'); if (otherTables.length === 0) { // New database, no detection needed @@ -113,7 +113,7 @@ export class UserDatabaseService { // Detect 001_complete_schema (check for users table) // This migration includes all schema, seed data, and indexes - if (otherTables.some(t => t.name === 'users')) { + if (otherTables.some((t) => t.name === 'users')) { markApplied('001_complete_schema', 'Detected existing schema and seed data'); } @@ -140,8 +140,9 @@ export class UserDatabaseService { const migrationDir = path.join(__dirname, '../migrations'); // Get all migration files (*.sql files with numeric prefix) - const files = fs.readdirSync(migrationDir) - .filter(f => f.endsWith('.sql') && /^\d{3}_/.test(f)) + const files = fs + .readdirSync(migrationDir) + .filter((f) => f.endsWith('.sql') && /^\d{3}_/.test(f)) .sort(); // Alphabetical sort ensures correct order (001, 002, etc.) logger.info(`[UserDB] Found ${files.length} migration file(s)`); @@ -151,9 +152,7 @@ export class UserDatabaseService { const migrationPath = path.join(migrationDir, file); // Check if migration has already been applied - const applied = this.db!.prepare( - 'SELECT 1 FROM migrations WHERE name = ?' - ).get(name); + const applied = this.db!.prepare('SELECT 1 FROM migrations WHERE name = ?').get(name); if (!applied) { logger.info(`[UserDB] Running migration: ${name}`); @@ -226,15 +225,19 @@ export class UserDatabaseService { static exec(sql: string, params: any[] = []): T[] { const db = this.getDatabase(); - return measureQueryPerformance(() => { - try { - const stmt = db.prepare(sql); - return stmt.all(params) as T[]; - } catch (error) { - logger.error('[UserDB] Query error:', { sql, params, error }); - throw error; - } - }, sql, params); + return measureQueryPerformance( + () => { + try { + const stmt = db.prepare(sql); + return stmt.all(params) as T[]; + } catch (error) { + logger.error('[UserDB] Query error:', { sql, params, error }); + throw error; + } + }, + sql, + params + ); } /** @@ -243,16 +246,20 @@ export class UserDatabaseService { static get(sql: string, params: any[] = []): T | undefined { const db = this.getDatabase(); - return measureQueryPerformance(() => { - try { - const stmt = db.prepare(sql); - const result = stmt.get(params); - return result as T | undefined; - } catch (error) { - logger.error('[UserDB] Get error:', { sql, params, error }); - throw error; - } - }, sql, params); + return measureQueryPerformance( + () => { + try { + const stmt = db.prepare(sql); + const result = stmt.get(params); + return result as T | undefined; + } catch (error) { + logger.error('[UserDB] Get error:', { sql, params, error }); + throw error; + } + }, + sql, + params + ); } /** @@ -268,19 +275,23 @@ export class UserDatabaseService { static run(sql: string, params: any[] = []): BetterSqlite3.RunResult { const db = this.getDatabase(); - return measureQueryPerformance(() => { - try { - const stmt = db.prepare(sql); - return stmt.run(params); - } catch (error) { - logger.error('[UserDB] Run error:', { sql, params }); - logger.error('[UserDB] Error details:', error); - if (error instanceof Error) { - logger.error('[UserDB] Error stack:', error.stack); + return measureQueryPerformance( + () => { + try { + const stmt = db.prepare(sql); + return stmt.run(params); + } catch (error) { + logger.error('[UserDB] Run error:', { sql, params }); + logger.error('[UserDB] Error details:', error); + if (error instanceof Error) { + logger.error('[UserDB] Error stack:', error.stack); + } + throw error; } - throw error; - } - }, sql, params); + }, + sql, + params + ); } /** diff --git a/backend/src/services/UserPlaylistService.ts b/backend/src/services/UserPlaylistService.ts index 9d7484c..5b956d8 100644 --- a/backend/src/services/UserPlaylistService.ts +++ b/backend/src/services/UserPlaylistService.ts @@ -171,12 +171,7 @@ export class UserPlaylistService { VALUES (?, ?, ?, ?) `); - const result = stmt.run( - userId, - data.title, - data.description || null, - data.icon || null - ); + const result = stmt.run(userId, data.title, data.description || null, data.icon || null); logger.info(`Created playlist "${data.title}" for user ${userId}`); @@ -219,7 +214,7 @@ export class UserPlaylistService { return playlist; } - updates.push('updated_at = datetime(\'now\')'); + updates.push("updated_at = datetime('now')"); values.push(playlistId, userId); const stmt = db.prepare(` @@ -381,7 +376,7 @@ export class UserPlaylistService { // Create new user playlist const userPlaylist = this.createPlaylist(userId, { title: newTitle || `${flashpointPlaylist.title} (Copy)`, - description: flashpointPlaylist.description || undefined + description: flashpointPlaylist.description || undefined, }); // Extract game IDs from flashpoint playlist @@ -430,7 +425,9 @@ export class UserPlaylistService { // Verify ownership const playlist = this.getPlaylistById(playlistId, userId); if (!playlist) { - logger.warn(`Cannot enable sharing for playlist ${playlistId}: not found or not owned by user ${userId}`); + logger.warn( + `Cannot enable sharing for playlist ${playlistId}: not found or not owned by user ${userId}` + ); return null; } @@ -448,20 +445,14 @@ export class UserPlaylistService { WHERE id = ? AND user_id = ? `); - stmt.run( - shareToken, - options.expiresAt || null, - options.showOwner ? 1 : 0, - playlistId, - userId - ); + stmt.run(shareToken, options.expiresAt || null, options.showOwner ? 1 : 0, playlistId, userId); logger.info(`Enabled sharing for playlist ${playlistId} (user ${userId})`); return { shareToken, expiresAt: options.expiresAt || null, - showOwner: options.showOwner || false + showOwner: options.showOwner || false, }; } @@ -475,7 +466,9 @@ export class UserPlaylistService { // Verify ownership const playlist = this.getPlaylistById(playlistId, userId); if (!playlist) { - logger.warn(`Cannot disable sharing for playlist ${playlistId}: not found or not owned by user ${userId}`); + logger.warn( + `Cannot disable sharing for playlist ${playlistId}: not found or not owned by user ${userId}` + ); return null; } @@ -502,7 +495,9 @@ export class UserPlaylistService { // Verify ownership const playlist = this.getPlaylistById(playlistId, userId); if (!playlist) { - logger.warn(`Cannot regenerate token for playlist ${playlistId}: not found or not owned by user ${userId}`); + logger.warn( + `Cannot regenerate token for playlist ${playlistId}: not found or not owned by user ${userId}` + ); return null; } @@ -523,7 +518,7 @@ export class UserPlaylistService { return { shareToken: newToken, expiresAt: playlist.shareExpiresAt || null, - showOwner: playlist.showOwner || false + showOwner: playlist.showOwner || false, }; } @@ -540,7 +535,9 @@ export class UserPlaylistService { // Verify ownership const playlist = this.getPlaylistById(playlistId, userId); if (!playlist) { - logger.warn(`Cannot update share settings for playlist ${playlistId}: not found or not owned by user ${userId}`); + logger.warn( + `Cannot update share settings for playlist ${playlistId}: not found or not owned by user ${userId}` + ); return null; } @@ -559,7 +556,7 @@ export class UserPlaylistService { stmt.run( options.expiresAt !== undefined ? options.expiresAt : playlist.shareExpiresAt, - options.showOwner !== undefined ? (options.showOwner ? 1 : 0) : (playlist.showOwner ? 1 : 0), + options.showOwner !== undefined ? (options.showOwner ? 1 : 0) : playlist.showOwner ? 1 : 0, playlistId, userId ); @@ -568,8 +565,9 @@ export class UserPlaylistService { return { shareToken: playlist.shareToken, - expiresAt: options.expiresAt !== undefined ? options.expiresAt : (playlist.shareExpiresAt || null), - showOwner: options.showOwner !== undefined ? options.showOwner : (playlist.showOwner || false) + expiresAt: + options.expiresAt !== undefined ? options.expiresAt : playlist.shareExpiresAt || null, + showOwner: options.showOwner !== undefined ? options.showOwner : playlist.showOwner || false, }; } @@ -591,7 +589,9 @@ export class UserPlaylistService { const playlist = stmt.get(shareToken) as UserPlaylist | undefined; if (!playlist) { - logger.debug(`Shared playlist not found or expired for token: ${shareToken.substring(0, 8)}...`); + logger.debug( + `Shared playlist not found or expired for token: ${shareToken.substring(0, 8)}...` + ); return null; } @@ -661,11 +661,7 @@ export class UserPlaylistService { * Creates new playlist with same title (+ " (Copy)"), description, icon * Copies all games with same order */ - cloneSharedPlaylist( - shareToken: string, - userId: number, - newTitle?: string - ): UserPlaylist | null { + cloneSharedPlaylist(shareToken: string, userId: number, newTitle?: string): UserPlaylist | null { const db = this.userDb.getDatabase(); // Get shared playlist @@ -680,7 +676,7 @@ export class UserPlaylistService { const newPlaylist = this.createPlaylist(userId, { title, description: sourcePlaylist.description || undefined, - icon: sourcePlaylist.icon || undefined + icon: sourcePlaylist.icon || undefined, }); // Copy games with same order diff --git a/backend/src/services/UserService.ts b/backend/src/services/UserService.ts index c8567da..2292451 100644 --- a/backend/src/services/UserService.ts +++ b/backend/src/services/UserService.ts @@ -51,19 +51,17 @@ export class UserService { */ async createUser(data: CreateUserData): Promise { // Check if username exists - const existingUser = UserDatabaseService.get( - 'SELECT id FROM users WHERE username = ?', - [data.username] - ); + const existingUser = UserDatabaseService.get('SELECT id FROM users WHERE username = ?', [ + data.username, + ]); if (existingUser) { throw new AppError(409, 'Username already exists'); } // Check if email exists - const existingEmail = UserDatabaseService.get( - 'SELECT id FROM users WHERE email = ?', - [data.email] - ); + const existingEmail = UserDatabaseService.get('SELECT id FROM users WHERE email = ?', [ + data.email, + ]); if (existingEmail) { throw new AppError(409, 'Email already exists'); } @@ -98,10 +96,10 @@ export class UserService { if (data.email !== undefined) { // Check if email is taken by another user - const existing = UserDatabaseService.get( - 'SELECT id FROM users WHERE email = ? AND id != ?', - [data.email, id] - ); + const existing = UserDatabaseService.get('SELECT id FROM users WHERE email = ? AND id != ?', [ + data.email, + id, + ]); if (existing) { throw new AppError(409, 'Email already exists'); } @@ -123,14 +121,11 @@ export class UserService { return user; // No changes } - updates.push("updated_at = ?"); + updates.push('updated_at = ?'); params.push(new Date().toISOString()); params.push(id); - UserDatabaseService.run( - `UPDATE users SET ${updates.join(', ')} WHERE id = ?`, - params - ); + UserDatabaseService.run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, params); // Invalidate permission cache if role changed if (data.roleId !== undefined) { @@ -152,10 +147,11 @@ export class UserService { // Prevent deleting the last admin if (user.roleName === 'admin') { - const adminCount = UserDatabaseService.get( - 'SELECT COUNT(*) as count FROM users u JOIN roles r ON u.role_id = r.id WHERE r.name = "admin"', - [] - )?.count || 0; + const adminCount = + UserDatabaseService.get( + 'SELECT COUNT(*) as count FROM users u JOIN roles r ON u.role_id = r.id WHERE r.name = "admin"', + [] + )?.count || 0; if (adminCount <= 1) { throw new AppError(403, 'Cannot delete the last admin user'); @@ -169,10 +165,7 @@ export class UserService { * Change user password */ async changePassword(id: number, currentPassword: string, newPassword: string): Promise { - const user = UserDatabaseService.get( - 'SELECT password_hash FROM users WHERE id = ?', - [id] - ); + const user = UserDatabaseService.get('SELECT password_hash FROM users WHERE id = ?', [id]); if (!user) { throw new AppError(404, 'User not found'); @@ -189,14 +182,13 @@ export class UserService { const passwordHash = await hashPassword(newPassword); // Update password - UserDatabaseService.run( - "UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?", - [passwordHash, new Date().toISOString(), id] - ); + UserDatabaseService.run('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?', [ + passwordHash, + new Date().toISOString(), + id, + ]); } - - /** * Get user setting by key */ @@ -218,10 +210,13 @@ export class UserService { [userId] ); - return results.reduce((acc: Record, row: any) => { - acc[row.setting_key] = row.setting_value; - return acc; - }, {} as Record); + return results.reduce( + (acc: Record, row: any) => { + acc[row.setting_key] = row.setting_value; + return acc; + }, + {} as Record + ); } /** @@ -266,7 +261,7 @@ export class UserService { return { mode: mode || 'dark', - primaryColor: primaryColor || 'blue' + primaryColor: primaryColor || 'blue', }; } } diff --git a/backend/src/telemetry/index.ts b/backend/src/telemetry/index.ts index 28fcd8c..4aae3e0 100644 --- a/backend/src/telemetry/index.ts +++ b/backend/src/telemetry/index.ts @@ -18,4 +18,9 @@ import { initializeTelemetry } from './otel'; initializeTelemetry(); // Re-export for convenience -export { initializeTelemetry, shutdownTelemetry, isTelemetryEnabled, getTelemetryConfig } from './otel'; +export { + initializeTelemetry, + shutdownTelemetry, + isTelemetryEnabled, + getTelemetryConfig, +} from './otel'; diff --git a/backend/src/telemetry/otel.ts b/backend/src/telemetry/otel.ts index 8083018..5dec183 100644 --- a/backend/src/telemetry/otel.ts +++ b/backend/src/telemetry/otel.ts @@ -4,7 +4,10 @@ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'; import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; import { resourceFromAttributes } from '@opentelemetry/resources'; -import { SEMRESATTRS_SERVICE_NAME, SEMRESATTRS_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'; +import { + SEMRESATTRS_SERVICE_NAME, + SEMRESATTRS_SERVICE_VERSION, +} from '@opentelemetry/semantic-conventions'; import { logger } from '../utils/logger'; /** @@ -42,7 +45,7 @@ function getOtelConfig() { tracesEnabled: process.env.OTEL_TRACES_ENABLED !== 'false', metricsEnabled: process.env.OTEL_METRICS_ENABLED !== 'false', metricsExportInterval: parseInt(process.env.OTEL_METRICS_EXPORT_INTERVAL || '60000', 10), - logLevel: process.env.OTEL_LOG_LEVEL || 'info' + logLevel: process.env.OTEL_LOG_LEVEL || 'info', }; } @@ -54,7 +57,7 @@ function getExporterHeaders(apiKey: string): Record { return {}; } return { - 'Authorization': `Api-Key ${apiKey}` + Authorization: `Api-Key ${apiKey}`, }; } @@ -76,14 +79,14 @@ export function initializeTelemetry(): void { endpoint: config.endpoint, service: config.serviceName, traces: config.tracesEnabled, - metrics: config.metricsEnabled + metrics: config.metricsEnabled, }); // Create resource (service metadata) const resource = resourceFromAttributes({ [SEMRESATTRS_SERVICE_NAME]: config.serviceName, [SEMRESATTRS_SERVICE_VERSION]: config.serviceVersion, - environment: process.env.NODE_ENV || 'development' + environment: process.env.NODE_ENV || 'development', }); // Build exporter headers @@ -117,10 +120,10 @@ export function initializeTelemetry(): void { getNodeAutoInstrumentations({ // Disable specific instrumentations if needed '@opentelemetry/instrumentation-fs': { - enabled: false // File system instrumentation can be noisy + enabled: false, // File system instrumentation can be noisy }, '@opentelemetry/instrumentation-dns': { - enabled: false + enabled: false, }, // HTTP instrumentation '@opentelemetry/instrumentation-http': { @@ -134,16 +137,16 @@ export function initializeTelemetry(): void { headersToSpanAttributes: { server: { requestHeaders: ['user-agent', 'content-type'], - responseHeaders: ['content-type'] - } - } + responseHeaders: ['content-type'], + }, + }, }, // Express instrumentation '@opentelemetry/instrumentation-express': { - enabled: true - } - }) - ] + enabled: true, + }, + }), + ], }); // Start SDK @@ -151,14 +154,13 @@ export function initializeTelemetry(): void { logger.info('[OpenTelemetry] Telemetry initialized successfully', { traces: config.tracesEnabled ? 'enabled' : 'disabled', - metrics: config.metricsEnabled ? 'enabled' : 'disabled' + metrics: config.metricsEnabled ? 'enabled' : 'disabled', }); // Graceful shutdown process.on('SIGTERM', () => { shutdownTelemetry(); }); - } catch (error) { logger.error('[OpenTelemetry] Failed to initialize telemetry:', error); // Non-fatal - application continues without telemetry diff --git a/backend/src/utils/activityUtils.ts b/backend/src/utils/activityUtils.ts index d5ad7cc..43acfec 100644 --- a/backend/src/utils/activityUtils.ts +++ b/backend/src/utils/activityUtils.ts @@ -4,14 +4,9 @@ export type ActionCategory = 'auth' | 'crud' | 'error' | 'system'; * Authentication-related actions used in SQL queries and analytics. * Used for aggregating auth events in getStats(), getTrend(), and getPreviousPeriodStats(). */ -export const AUTH_ACTIONS = [ - 'login', - 'logout', - 'register', - 'auth.login.failed' -] as const; +export const AUTH_ACTIONS = ['login', 'logout', 'register', 'auth.login.failed'] as const; -export type AuthAction = typeof AUTH_ACTIONS[number]; +export type AuthAction = (typeof AUTH_ACTIONS)[number]; /** * Categorize an action string into one of four categories. @@ -29,11 +24,7 @@ export function categorizeAction(action: string): ActionCategory { ) { return 'auth'; } - if ( - action.includes('schedule') || - action.includes('cleanup') || - action.includes('maintenance') - ) { + if (action.includes('schedule') || action.includes('cleanup') || action.includes('maintenance')) { return 'system'; } return 'crud'; diff --git a/backend/src/utils/benchmarkGameSearch.ts b/backend/src/utils/benchmarkGameSearch.ts index 70501ec..9035012 100644 --- a/backend/src/utils/benchmarkGameSearch.ts +++ b/backend/src/utils/benchmarkGameSearch.ts @@ -94,17 +94,13 @@ async function runAllBenchmarks(): Promise { benchmarks.push(await runBenchmark('Library: arcade', { library: 'arcade' })); // Benchmark 6: Year range - benchmarks.push( - await runBenchmark('Years: 2000-2010', { yearFrom: 2000, yearTo: 2010 }) - ); + benchmarks.push(await runBenchmark('Years: 2000-2010', { yearFrom: 2000, yearTo: 2010 })); // Benchmark 7: Tag filter (single tag) benchmarks.push(await runBenchmark('Tag: Puzzle', { tags: ['Puzzle'] })); // Benchmark 8: Multiple tags - benchmarks.push( - await runBenchmark('Tags: Puzzle + Action', { tags: ['Puzzle', 'Action'] }) - ); + benchmarks.push(await runBenchmark('Tags: Puzzle + Action', { tags: ['Puzzle', 'Action'] })); // Benchmark 9: Complex filter combination benchmarks.push( @@ -168,11 +164,11 @@ async function runAllBenchmarks(): Promise { console.log('└────┴──────────────────────────────────┴──────────┴────────┴──────────┘\n'); // Summary statistics - const times = benchmarks.map(b => b.executionTime); + const times = benchmarks.map((b) => b.executionTime); const avgTime = Math.round(times.reduce((a, b) => a + b, 0) / times.length); const minTime = Math.min(...times); const maxTime = Math.max(...times); - const slowQueries = benchmarks.filter(b => b.executionTime >= 100); + const slowQueries = benchmarks.filter((b) => b.executionTime >= 100); console.log('SUMMARY:\n'); console.log(` Average execution time: ${avgTime}ms`); @@ -182,7 +178,7 @@ async function runAllBenchmarks(): Promise { if (slowQueries.length > 0) { console.log('\n⚠️ SLOW QUERIES DETECTED:\n'); - slowQueries.forEach(q => { + slowQueries.forEach((q) => { console.log(` - ${q.name}: ${q.executionTime}ms`); }); } diff --git a/backend/src/utils/jwt.ts b/backend/src/utils/jwt.ts index c8fb840..b03a9e8 100644 --- a/backend/src/utils/jwt.ts +++ b/backend/src/utils/jwt.ts @@ -18,7 +18,7 @@ export interface JWTPayload { */ export function generateAccessToken(payload: JWTPayload): string { return jwt.sign(payload, config.jwtSecret, { - expiresIn: config.jwtExpiresIn as string | number + expiresIn: config.jwtExpiresIn as string | number, } as jwt.SignOptions); } diff --git a/backend/src/utils/logger.ts b/backend/src/utils/logger.ts index 4a4b91d..e52aa85 100644 --- a/backend/src/utils/logger.ts +++ b/backend/src/utils/logger.ts @@ -29,7 +29,7 @@ function getTraceContext(): { trace_id?: string; span_id?: string } { const spanContext = activeSpan.spanContext(); return { trace_id: spanContext.traceId, - span_id: spanContext.spanId + span_id: spanContext.spanId, }; } } catch { @@ -44,13 +44,13 @@ function getTraceContext(): { trace_id?: string; span_id?: string } { */ function getSeverityNumber(level: string): number { const severityMap: Record = { - error: 17, // ERROR - warn: 13, // WARN - info: 9, // INFO - http: 9, // INFO - verbose: 5, // DEBUG - debug: 5, // DEBUG - silly: 1 // TRACE + error: 17, // ERROR + warn: 13, // WARN + info: 9, // INFO + http: 9, // INFO + verbose: 5, // DEBUG + debug: 5, // DEBUG + silly: 1, // TRACE }; return severityMap[level] || 9; } @@ -98,8 +98,8 @@ const transports: winston.transport[] = [ } return msg; }) - ) - }) + ), + }), ]; // Track file transport for diagnostics @@ -128,7 +128,9 @@ if (config.logFile) { fs.unlinkSync(testFile); console.log(`[Logger] Directory is writable`); } catch (writeError) { - throw new Error(`Log directory is not writable: ${writeError instanceof Error ? writeError.message : writeError}`); + throw new Error( + `Log directory is not writable: ${writeError instanceof Error ? writeError.message : writeError}` + ); } // Create file transport with OTEL-compatible JSON format @@ -142,7 +144,7 @@ if (config.logFile) { ), maxsize: 10485760, // 10MB maxFiles: 5, - tailable: true + tailable: true, }); // Add error handler to catch write failures @@ -172,7 +174,7 @@ export const logger = winston.createLogger({ winston.format.json() ), defaultMeta: { service: 'flashpoint-web-backend' }, - transports + transports, }); /** @@ -192,7 +194,7 @@ export function getLoggingStatus(): { filePath: config.logFile || null, fileError: fileLoggingError?.message || null, logLevel: config.logLevel, - otelIntegration: trace !== null + otelIntegration: trace !== null, }; } diff --git a/backend/src/utils/pagination.ts b/backend/src/utils/pagination.ts index e50d517..cd6a063 100644 --- a/backend/src/utils/pagination.ts +++ b/backend/src/utils/pagination.ts @@ -28,8 +28,8 @@ export function createPaginatedResponse( total, page, limit, - totalPages - } + totalPages, + }, }; } @@ -56,13 +56,10 @@ export function normalizePagination( normalizedPage = Math.max(1, normalizedPage || 1); // Ensure limit is within bounds - normalizedLimit = Math.min( - maxLimit, - Math.max(1, normalizedLimit || defaultLimit) - ); + normalizedLimit = Math.min(maxLimit, Math.max(1, normalizedLimit || defaultLimit)); return { page: normalizedPage, - limit: normalizedLimit + limit: normalizedLimit, }; } diff --git a/backend/src/utils/queryParser.ts b/backend/src/utils/queryParser.ts index 2dca3f0..35918a8 100644 --- a/backend/src/utils/queryParser.ts +++ b/backend/src/utils/queryParser.ts @@ -40,17 +40,11 @@ export function parsePaginationQuery( query: any, defaults: PaginationDefaults = {} ): PaginationResult { - const { - limit: defaultLimit = 50, - maxLimit = 100, - offset: defaultOffset = 0, - } = defaults; + const { limit: defaultLimit = 50, maxLimit = 100, offset: defaultOffset = 0 } = defaults; // Parse limit with default and max cap const parsedLimit = parseInt(query.limit as string); - const limit = isNaN(parsedLimit) - ? defaultLimit - : Math.min(parsedLimit, maxLimit); + const limit = isNaN(parsedLimit) ? defaultLimit : Math.min(parsedLimit, maxLimit); // Parse offset with default const parsedOffset = parseInt(query.offset as string); diff --git a/backend/src/utils/queryPerformance.ts b/backend/src/utils/queryPerformance.ts index 1e05de0..8d1b60c 100644 --- a/backend/src/utils/queryPerformance.ts +++ b/backend/src/utils/queryPerformance.ts @@ -70,11 +70,7 @@ function logSlowQuery(metrics: QueryMetrics): void { * @param params - Query parameters * @returns The result of the query function */ -export function measureQueryPerformance( - queryFn: () => T, - sql: string, - params: any[] = [] -): T { +export function measureQueryPerformance(queryFn: () => T, sql: string, params: any[] = []): T { const start = performance.now(); try { diff --git a/docs/01-overview/project-overview.md b/docs/01-overview/project-overview.md index be0d4dd..165ff7b 100644 --- a/docs/01-overview/project-overview.md +++ b/docs/01-overview/project-overview.md @@ -2,15 +2,22 @@ ## Introduction -Flashpoint Web is a self-hosted web application designed to browse and play games from the Flashpoint Archive. It provides a modern, user-friendly interface to access thousands of preserved Flash games, HTML5 games, and animations from the early web era. +Flashpoint Web is a self-hosted web application designed to browse and play +games from the Flashpoint Archive. It provides a modern, user-friendly interface +to access thousands of preserved Flash games, HTML5 games, and animations from +the early web era. ## What is Flashpoint? -[Flashpoint](https://flashpointarchive.org/) is a community preservation project dedicated to saving games and animations from the web. With Adobe Flash Player's end-of-life in December 2020, millions of Flash games and animations became unplayable. Flashpoint preserves these games and provides tools to play them. +[Flashpoint](https://flashpointarchive.org/) is a community preservation project +dedicated to saving games and animations from the web. With Adobe Flash Player's +end-of-life in December 2020, millions of Flash games and animations became +unplayable. Flashpoint preserves these games and provides tools to play them. ## What is Flashpoint Web? -Flashpoint Web is a **web-based alternative** to the Flashpoint Launcher, offering: +Flashpoint Web is a **web-based alternative** to the Flashpoint Launcher, +offering: - **Browser-based access** to your local Flashpoint collection - **Modern UI** built with React and Tailwind CSS @@ -24,7 +31,8 @@ Flashpoint Web is a **web-based alternative** to the Flashpoint Launcher, offeri ### Game Browsing & Search -- **Comprehensive filtering** by platform, developer, publisher, series, tags, year, and more +- **Comprehensive filtering** by platform, developer, publisher, series, tags, + year, and more - **Fast search** across 70,000+ games in the archive - **Multiple view modes** (grid/list) with customizable layouts - **Related games** suggestions based on developer and platform @@ -59,11 +67,14 @@ Flashpoint Web is a **web-based alternative** to the Flashpoint Launcher, offeri Flashpoint Web consists of three independent services: -1. **Backend (Port 3100)** - REST API server handling game metadata, user management, and authentication +1. **Backend (Port 3100)** - REST API server handling game metadata, user + management, and authentication 2. **Frontend (Port 5173)** - React web application providing the user interface -3. **Game Service (Ports 22500/22501)** - File serving proxy for game content and ZIP archives +3. **Game Service (Ports 22500/22501)** - File serving proxy for game content + and ZIP archives This separation allows: + - Independent scaling of services - Backend non-blocking on large file transfers - Game service caching and CDN compatibility @@ -72,58 +83,68 @@ This separation allows: ## Technology Stack Summary - **Backend**: Node.js, Express, TypeScript, SQLite (BetterSqlite3), JWT, bcrypt -- **Frontend**: React 18, TypeScript, Vite, TanStack Query, Zustand, Tailwind CSS, Shadcn UI +- **Frontend**: React 18, TypeScript, Vite, TanStack Query, Zustand, Tailwind + CSS, Shadcn UI - **Game Service**: Node.js, TypeScript, node-stream-zip, Axios - **Game Emulation**: Ruffle (Flash), Native Browser (HTML5) ## Use Cases ### Home Users -- Browse and play your personal Flashpoint collection from any device on your local network + +- Browse and play your personal Flashpoint collection from any device on your + local network - Track your playtime and discover new games - Create playlists for organizing favorites ### Multi-User Households + - Individual user accounts with separate play statistics - Guest mode for temporary access - User registration for creating accounts ### Educational Institutions + - Preserve and provide access to educational Flash content - Track student engagement with educational games - Role-based access for teachers and students ### Game Preservationists + - Access comprehensive metadata from Flashpoint's preservation efforts - Contribute to playlists and curation - Integration with existing Flashpoint installations ## Project Goals -1. **Preservation** - Support the Flashpoint mission of preserving web games and animations -2. **Accessibility** - Make preserved content easy to access via modern web interfaces -3. **User Experience** - Provide intuitive, responsive UI for browsing and playing +1. **Preservation** - Support the Flashpoint mission of preserving web games and + animations +2. **Accessibility** - Make preserved content easy to access via modern web + interfaces +3. **User Experience** - Provide intuitive, responsive UI for browsing and + playing 4. **Extensibility** - Modular architecture allowing future enhancements 5. **Performance** - Fast searches, efficient file serving, responsive UI 6. **Security** - Secure authentication, permission system, audit logging ## Comparison with Flashpoint Launcher -| Feature | Flashpoint Launcher | Flashpoint Web | -|---------|-------------------|----------------| -| Platform | Desktop (Windows, Linux, Mac) | Web browser | -| Interface | Desktop application | Web application | -| Game Support | All platforms | Flash, HTML5 (browser-playable) | -| Authentication | Single user | Multi-user with RBAC | -| Play Tracking | Local only | Per-user with statistics | -| Remote Access | No | Yes (local network) | -| Search/Filter | Basic | Advanced with multiple criteria | -| Playlists | Launcher-managed | User-created, favorites | -| Updates | Manual | Database hot-reload | +| Feature | Flashpoint Launcher | Flashpoint Web | +| -------------- | ----------------------------- | ------------------------------- | +| Platform | Desktop (Windows, Linux, Mac) | Web browser | +| Interface | Desktop application | Web application | +| Game Support | All platforms | Flash, HTML5 (browser-playable) | +| Authentication | Single user | Multi-user with RBAC | +| Play Tracking | Local only | Per-user with statistics | +| Remote Access | No | Yes (local network) | +| Search/Filter | Basic | Advanced with multiple criteria | +| Playlists | Launcher-managed | User-created, favorites | +| Updates | Manual | Database hot-reload | ## License -Flashpoint Web is open-source software. (Note: Add appropriate license information) +Flashpoint Web is open-source software. (Note: Add appropriate license +information) ## Credits @@ -135,9 +156,11 @@ Flashpoint Web is open-source software. (Note: Add appropriate license informati Ready to dive in? -1. [System Architecture](../02-architecture/system-architecture.md) - Understand the system design +1. [System Architecture](../02-architecture/system-architecture.md) - Understand + the system design 2. [Technology Stack](technology-stack.md) - Learn about the technologies used -3. [Setup Guide](../08-development/setup-guide.md) - Set up your development environment +3. [Setup Guide](../08-development/setup-guide.md) - Set up your development + environment ## Related Links diff --git a/docs/01-overview/technology-stack.md b/docs/01-overview/technology-stack.md index dc9168c..913d695 100644 --- a/docs/01-overview/technology-stack.md +++ b/docs/01-overview/technology-stack.md @@ -1,45 +1,46 @@ # Technology Stack -Modern, production-ready technologies chosen for performance, developer experience, and ecosystem maturity. +Modern, production-ready technologies chosen for performance, developer +experience, and ecosystem maturity. ## Stack Overview ### Backend Technologies -| Technology | Version | Purpose | -|------------|---------|---------| -| Node.js | 20+ | JavaScript runtime | -| Express | 4.18 | Web framework | -| TypeScript | 5.4 | Type-safe JavaScript | -| BetterSqlite3 | 12.6 | SQLite database driver | -| JWT | 9.0 | Authentication tokens | -| bcrypt | 6.0 | Password hashing | -| Winston | 3.11 | Logging | +| Technology | Version | Purpose | +| ------------- | ------- | ---------------------- | +| Node.js | 20+ | JavaScript runtime | +| Express | 4.18 | Web framework | +| TypeScript | 5.4 | Type-safe JavaScript | +| BetterSqlite3 | 12.6 | SQLite database driver | +| JWT | 9.0 | Authentication tokens | +| bcrypt | 6.0 | Password hashing | +| Winston | 3.11 | Logging | ### Frontend Technologies -| Technology | Version | Purpose | -|------------|---------|---------| -| React | 18.3 | UI library | -| TypeScript | 5.4 | Type-safe JavaScript | -| Vite | 5.2 | Build tool and dev server | -| React Router | 6.22 | Client-side routing | -| TanStack Query | 5.28 | Server state management | -| Zustand | 4.5 | Client state management | -| Tailwind CSS | 3.4 | Utility-first CSS | -| Shadcn UI | Latest | Component library | -| Ruffle | 0.2.0 | Flash emulator | +| Technology | Version | Purpose | +| -------------- | ------- | ------------------------- | +| React | 18.3 | UI library | +| TypeScript | 5.4 | Type-safe JavaScript | +| Vite | 5.2 | Build tool and dev server | +| React Router | 6.22 | Client-side routing | +| TanStack Query | 5.28 | Server state management | +| Zustand | 4.5 | Client state management | +| Tailwind CSS | 3.4 | Utility-first CSS | +| Shadcn UI | Latest | Component library | +| Ruffle | 0.2.0 | Flash emulator | ### Game Service Technologies -| Technology | Version | Purpose | -|------------|---------|---------| -| Node.js | 20+ | JavaScript runtime | -| Express | 4.18 | HTTP server | -| TypeScript | 5.3 | Type safety | -| node-stream-zip | 1.15 | ZIP archive streaming | -| Axios | 1.6 | HTTP client | -| Winston | 3.11 | Logging | +| Technology | Version | Purpose | +| --------------- | ------- | --------------------- | +| Node.js | 20+ | JavaScript runtime | +| Express | 4.18 | HTTP server | +| TypeScript | 5.3 | Type safety | +| node-stream-zip | 1.15 | ZIP archive streaming | +| Axios | 1.6 | HTTP client | +| Winston | 3.11 | Logging | --- @@ -50,12 +51,14 @@ Modern, production-ready technologies chosen for performance, developer experien JavaScript runtime built on Chrome's V8 engine. **Why chosen:** + - Same language as frontend (JavaScript/TypeScript) - Excellent async I/O for concurrent requests - Large npm ecosystem - Great performance **Use cases:** + - REST API server - Database operations - File watching (hot-reload) @@ -66,16 +69,18 @@ JavaScript runtime built on Chrome's V8 engine. Minimal and flexible web application framework. **Why chosen:** + - Mature and battle-tested - Perfect middleware pattern - Large ecosystem - Fast routing **Key middleware:** + ```typescript app.use(cors({ origin: process.env.DOMAIN })); -app.use(helmet()); // Security headers -app.use(compression()); // Response compression +app.use(helmet()); // Security headers +app.use(compression()); // Response compression app.use('/api/auth', rateLimit({ max: 5 })); // Rate limiting ``` @@ -84,6 +89,7 @@ app.use('/api/auth', rateLimit({ max: 5 })); // Rate limiting Typed superset of JavaScript. **Why chosen:** + - Type safety catches errors at compile time - Excellent IDE support - Code quality enforcement @@ -94,12 +100,14 @@ Typed superset of JavaScript. Fastest SQLite3 library for Node.js. **Why chosen over alternatives:** + - Synchronous API (simpler than async wrappers) - 2-3x faster than async alternatives - Fewer race conditions - Perfect for SQLite workloads **Use:** + ```typescript // Read-only connection to flashpoint.sqlite const fpDb = new Database(config.flashpointDbPath, { readonly: true }); @@ -108,7 +116,9 @@ const fpDb = new Database(config.flashpointDbPath, { readonly: true }); const userDb = new Database(USER_DB_PATH); // Prepared statement -const stmt = fpDb.prepare('SELECT * FROM game WHERE title LIKE ? AND platform = ?'); +const stmt = fpDb.prepare( + 'SELECT * FROM game WHERE title LIKE ? AND platform = ?' +); const games = stmt.all(`%${search}%`, platform); ``` @@ -116,7 +126,8 @@ const games = stmt.all(`%${search}%`, platform); **JWT**: Stateless authentication, works across instances, mobile-friendly. -**bcrypt**: Designed for passwords, slow by design (resistant to brute-force), automatic salt generation. +**bcrypt**: Designed for passwords, slow by design (resistant to brute-force), +automatic salt generation. --- @@ -127,12 +138,14 @@ const games = stmt.all(`%${search}%`, platform); JavaScript library for building user interfaces. **Why chosen:** + - Component model - reusable, composable UI - Largest React library ecosystem - Virtual DOM for performance - Excellent tooling **React 18 features:** + - Automatic batching - Concurrent rendering - Transitions for smooth updates @@ -142,6 +155,7 @@ JavaScript library for building user interfaces. Next-generation frontend build tool. **Why chosen over alternatives:** + - Instant HMR via native ES modules - Fast production builds (esbuild) - Simple configuration @@ -152,12 +166,14 @@ Next-generation frontend build tool. Powerful data synchronization library. **Why chosen:** + - Smart cache management - Background refetching - Built-in loading/error states - Excellent DevTools **Usage:** + ```typescript function useGames(filters: GameFilters) { return useQuery({ @@ -174,6 +190,7 @@ function useGames(filters: GameFilters) { Small, fast state management library. **Why chosen:** + - Minimal boilerplate - Optimized re-renders - TypeScript-first @@ -181,6 +198,7 @@ Small, fast state management library. - Only 1KB gzipped **Usage:** + ```typescript const useAuthStore = create((set) => ({ user: null, @@ -201,6 +219,7 @@ const useAuthStore = create((set) => ({ Utility-first CSS framework. **Why chosen:** + - Build UIs without custom CSS - Consistent design system - Automatic CSS purging @@ -212,6 +231,7 @@ Utility-first CSS framework. Re-usable components built with Radix UI and Tailwind. **Why chosen:** + - Copy components to your project - Built on accessible Radix primitives - Pre-designed component system @@ -223,6 +243,7 @@ Re-usable components built with Radix UI and Tailwind. Flash Player emulator written in Rust, compiled to WebAssembly. **Why needed:** + - Adobe Flash reached EOL - Runs in modern browsers - Sandboxed WebAssembly execution @@ -237,12 +258,14 @@ Flash Player emulator written in Rust, compiled to WebAssembly. Zero-dependency ZIP file streaming library. **Why chosen:** + - Streaming (no extraction required) - Memory efficient random access - Fast direct reads - Simple API **Usage:** + ```typescript const zip = new StreamZip.async({ file: '/path/to/game.zip' }); const entry = await zip.entry(filePath); @@ -273,6 +296,7 @@ Unit testing framework with Vite integration. ### Decision Criteria For each technology, evaluated: + 1. Performance 2. Developer experience 3. Ecosystem @@ -286,35 +310,35 @@ For each technology, evaluated: #### React vs Vue vs Svelte -| Criteria | React | Vue | Svelte | -|----------|-------|-----|--------| -| Ecosystem | Excellent | Good | Growing | -| TypeScript | Excellent | Good | Good | -| Community | Largest | Large | Smaller | -| Bundle Size | Larger | Medium | Smallest | -| **Decision** | **✓ Chosen** | ✗ | ✗ | +| Criteria | React | Vue | Svelte | +| ------------ | ------------ | ------ | -------- | +| Ecosystem | Excellent | Good | Growing | +| TypeScript | Excellent | Good | Good | +| Community | Largest | Large | Smaller | +| Bundle Size | Larger | Medium | Smallest | +| **Decision** | **✓ Chosen** | ✗ | ✗ | **Reason**: Largest ecosystem (Shadcn UI, TanStack Query) #### TanStack Query vs Redux Toolkit Query vs SWR -| Criteria | TanStack | RTK Query | SWR | -|----------|----------|-----------|-----| -| Features | Most | Many | Fewer | -| TypeScript | Excellent | Good | Good | -| Boilerplate | Low | Medium | Lowest | -| **Decision** | **✓ Chosen** | ✗ | ✗ | +| Criteria | TanStack | RTK Query | SWR | +| ------------ | ------------ | --------- | ------ | +| Features | Most | Many | Fewer | +| TypeScript | Excellent | Good | Good | +| Boilerplate | Low | Medium | Lowest | +| **Decision** | **✓ Chosen** | ✗ | ✗ | **Reason**: Best balance of features and DX #### SQLite vs PostgreSQL vs MongoDB -| Criteria | SQLite | PostgreSQL | MongoDB | -|----------|--------|------------|---------| -| Setup | Embedded | Server | Server | -| Flashpoint | Native | Migration | Migration | -| Complexity | Lowest | Medium | Medium | -| **Decision** | **✓ Chosen** | ✗ | ✗ | +| Criteria | SQLite | PostgreSQL | MongoDB | +| ------------ | ------------ | ---------- | --------- | +| Setup | Embedded | Server | Server | +| Flashpoint | Native | Migration | Migration | +| Complexity | Lowest | Medium | Medium | +| **Decision** | **✓ Chosen** | ✗ | ✗ | **Reason**: Flashpoint compatibility, self-hosted simplicity @@ -323,6 +347,7 @@ For each technology, evaluated: ## Package Dependencies ### Backend Production + ```json { "axios": "^1.13.2", @@ -335,6 +360,7 @@ For each technology, evaluated: ``` ### Frontend Production + ```json { "@ruffle-rs/ruffle": "^0.2.0-nightly", @@ -353,6 +379,7 @@ For each technology, evaluated: ### Bundle Sizes **Frontend (Production Build):** + - Main bundle: ~150 KB (gzipped) - React vendor: ~130 KB (gzipped) - Shadcn UI: ~50 KB (gzipped) @@ -362,16 +389,19 @@ For each technology, evaluated: ### Runtime Performance **Backend API:** + - Average response: <50ms - Game search: <100ms - Database queries: <10ms (indexed) **Frontend:** + - First contentful paint: <1s - Time to interactive: <2s - Game grid render: <100ms (virtualized) **Game Service:** + - File streaming: Wire speed - ZIP file access: <50ms - CDN fallback: 200-500ms (network dependent) @@ -381,12 +411,14 @@ For each technology, evaluated: ## Browser Support **Required features:** + - ES2020 support - WebAssembly (for Ruffle) - CSS Grid and Flexbox - localStorage **Targeted browsers:** + - Chrome/Edge: Last 2 versions - Firefox: Last 2 versions - Safari: Last 2 versions @@ -395,7 +427,8 @@ For each technology, evaluated: ## Related Documentation -- [Architecture Overview](../02-architecture/system-architecture.md) - System design +- [Architecture Overview](../02-architecture/system-architecture.md) - System + design - [Backend Guide](../03-backend/README.md) - Backend details - [Frontend Guide](../04-frontend/README.md) - Frontend details - [Deployment Guide](../09-deployment/README.md) - Production deployment diff --git a/docs/02-architecture/README.md b/docs/02-architecture/README.md index 3844d41..a4d2d44 100644 --- a/docs/02-architecture/README.md +++ b/docs/02-architecture/README.md @@ -1,6 +1,8 @@ # Architecture Documentation -This directory contains comprehensive architecture documentation for Flashpoint Web, detailing the system design, service communication patterns, and data flows. +This directory contains comprehensive architecture documentation for Flashpoint +Web, detailing the system design, service communication patterns, and data +flows. ## Documentation Structure @@ -9,6 +11,7 @@ This directory contains comprehensive architecture documentation for Flashpoint **Deep dive into the complete system architecture** Topics covered: + - Three-service architecture overview (Frontend, Backend, Game Service) - Technology stack and rationale - Service responsibilities and boundaries @@ -20,13 +23,15 @@ Topics covered: - Error handling strategies - Monitoring and logging -**When to read**: Start here for a high-level understanding of the entire system. +**When to read**: Start here for a high-level understanding of the entire +system. ### 2. [Service Communication](./service-communication.md) **Inter-service communication patterns and protocols** Topics covered: + - Frontend ↔ Backend communication (REST API) - Backend ↔ Game Service communication (internal APIs) - Frontend ↔ Game Service communication (direct file loading) @@ -39,13 +44,15 @@ Topics covered: - Performance optimization - WebSocket (future considerations) -**When to read**: When you need to understand how services communicate or add new API endpoints. +**When to read**: When you need to understand how services communicate or add +new API endpoints. ### 3. [Data Flow Diagrams](./data-flow-diagrams.md) **Complete request/response flows with Mermaid diagrams** Topics covered: + - Game browse and search flow - Game detail view flow - Filter options loading @@ -56,13 +63,15 @@ Topics covered: - Sort order changes - Cache management (stale-while-revalidate) -**When to read**: When debugging data flow issues or understanding user journeys through the system. +**When to read**: When debugging data flow issues or understanding user journeys +through the system. ### 4. [Authentication Flow](./authentication-flow.md) **Complete authentication and authorization flow** Topics covered: + - User registration flow - User login flow - Protected request flow @@ -75,13 +84,15 @@ Topics covered: - Security best practices - Common authentication issues -**When to read**: When implementing authentication features or troubleshooting auth issues. +**When to read**: When implementing authentication features or troubleshooting +auth issues. ### 5. [Game Launch Flow](./game-launch-flow.md) **Detailed game playing flow from click to play** Topics covered: + - Game launch request flow - ZIP mounting flow - Game content loading (Flash and HTML5) @@ -92,13 +103,15 @@ Topics covered: - Error handling - Performance optimization -**When to read**: When debugging game loading issues or understanding how games are served. +**When to read**: When debugging game loading issues or understanding how games +are served. ### 6. [Play Tracking Flow](./play-tracking-flow.md) **Session tracking and statistics aggregation** Topics covered: + - Session start flow - Session end flow - Statistics aggregation (per-game and per-user) @@ -108,15 +121,18 @@ Topics covered: - Database schema and indexing - Privacy and data management -**When to read**: When implementing analytics features or understanding play session tracking. +**When to read**: When implementing analytics features or understanding play +session tracking. ## Architecture Diagrams -All architecture documents include Mermaid diagrams for visual representation. These diagrams are rendered automatically in GitHub and most Markdown viewers. +All architecture documents include Mermaid diagrams for visual representation. +These diagrams are rendered automatically in GitHub and most Markdown viewers. ### Key Diagram Types **System Diagrams**: Show the overall architecture and service relationships + ```mermaid graph TB Frontend --> Backend @@ -124,6 +140,7 @@ graph TB ``` **Sequence Diagrams**: Show step-by-step interactions between components + ```mermaid sequenceDiagram User->>Frontend: Click button @@ -132,6 +149,7 @@ sequenceDiagram ``` **Flow Diagrams**: Show decision trees and process flows + ```mermaid graph LR Start --> Decision{Condition?} @@ -151,11 +169,13 @@ Business logic lives in service classes, routes are thin controllers. ### 2. Repository Pattern (Implicit) -Database access is abstracted through `DatabaseService` and `UserDatabaseService`. +Database access is abstracted through `DatabaseService` and +`UserDatabaseService`. ### 3. Middleware Pipeline Express middleware chains handle cross-cutting concerns: + - Authentication (JWT verification) - Authorization (RBAC) - Logging (activity tracking) @@ -165,12 +185,14 @@ Express middleware chains handle cross-cutting concerns: ### 4. State Management **Frontend**: + - Server state: TanStack Query - UI state: Zustand - URL state: React Router - Form state: React Hook Form **Backend**: + - Stateless (JWT for session) - Database as source of truth @@ -388,9 +410,11 @@ Express middleware chains handle cross-cutting concerns: ## Related Documentation -- [Overview Documentation](../01-overview/): High-level overview and getting started +- [Overview Documentation](../01-overview/): High-level overview and getting + started - [API Reference](../03-api/): Detailed API endpoint documentation (if exists) -- [Development Guide](../04-development/): Development setup and workflows (if exists) +- [Development Guide](../04-development/): Development setup and workflows (if + exists) - [Deployment Guide](../05-deployment/): Production deployment (if exists) ## Contributing diff --git a/docs/02-architecture/authentication-flow.md b/docs/02-architecture/authentication-flow.md index 04a490c..e5faf27 100644 --- a/docs/02-architecture/authentication-flow.md +++ b/docs/02-architecture/authentication-flow.md @@ -2,7 +2,8 @@ ## Overview -Flashpoint Web uses JWT-based authentication with role-based access control (RBAC). +Flashpoint Web uses JWT-based authentication with role-based access control +(RBAC). ## Authentication Architecture @@ -95,16 +96,19 @@ sequenceDiagram ``` **Password Validation Schema**: + ```typescript -const registerSchema = z.object({ - username: z.string().min(3).max(50), - email: z.string().email(), - password: z.string().min(6), - confirmPassword: z.string() -}).refine(data => data.password === data.confirmPassword, { - message: "Passwords don't match", - path: ["confirmPassword"] -}); +const registerSchema = z + .object({ + username: z.string().min(3).max(50), + email: z.string().email(), + password: z.string().min(6), + confirmPassword: z.string(), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords don't match", + path: ['confirmPassword'], + }); ``` ## 2. User Login Flow @@ -150,6 +154,7 @@ sequenceDiagram ``` **JWT Token Structure**: + ```typescript // Access Token { @@ -213,6 +218,7 @@ sequenceDiagram ``` **Authentication Middleware**: + ```typescript export const authenticate = async (req, res, next) => { try { @@ -230,7 +236,7 @@ export const authenticate = async (req, res, next) => { username: user.username, email: user.email, role: user.roleName, - permissions: user.permissions + permissions: user.permissions, }; next(); @@ -241,6 +247,7 @@ export const authenticate = async (req, res, next) => { ``` **RBAC Middleware**: + ```typescript export const requirePermission = (permission: string) => { return (req, res, next) => { @@ -293,6 +300,7 @@ sequenceDiagram ``` **Axios Response Interceptor**: + ```typescript api.interceptors.response.use( (response) => response, @@ -369,6 +377,7 @@ graph TB ``` **Database Schema**: + ```sql CREATE TABLE roles ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -395,12 +404,14 @@ CREATE TABLE role_permissions ( ``` **Default Roles**: + - `admin`: Full system access - `moderator`: Content management and user support - `user`: Standard user features (games, playlists) - `guest`: Read-only access (if enabled) **Common Permissions**: + ``` games.read, games.play playlists.read, playlists.create, playlists.update, playlists.delete @@ -410,6 +421,7 @@ settings.update ``` **Frontend Permission Checks**: + ```typescript export const ProtectedRoute = ({ children, requiredPermission }) => { const { isAuthenticated, hasPermission } = useAuthStore(); @@ -433,7 +445,8 @@ export const ProtectedRoute = ({ children, requiredPermission }) => { ## 7. Guest Mode -When `auth.guest_access_enabled` is true in system settings, unauthenticated users can browse games with limited permissions. +When `auth.guest_access_enabled` is true in system settings, unauthenticated +users can browse games with limited permissions. ```typescript // Frontend @@ -441,7 +454,7 @@ const guestUser: User = { id: 0, username: 'Guest', role: 'guest', - permissions: ['games.read', 'games.play'] + permissions: ['games.read', 'games.play'], }; // Backend @@ -457,7 +470,12 @@ export const optionalAuth = async (req, res, next) => { if (!settings.guestAccessEnabled) { throw new AppError(401, 'Authentication required'); } - req.user = { id: 0, username: 'guest', role: 'guest', permissions: ['games.read'] }; + req.user = { + id: 0, + username: 'guest', + role: 'guest', + permissions: ['games.read'], + }; } next(); @@ -469,6 +487,7 @@ export const optionalAuth = async (req, res, next) => { **Password Security**: Bcrypt with 10 rounds **JWT Security**: + - Short-lived access tokens (15 minutes) - Long-lived refresh tokens (7 days) - Signed with random 256-bit JWT_SECRET @@ -482,10 +501,10 @@ export const optionalAuth = async (req, res, next) => { ## Troubleshooting -| Issue | Cause | Solution | -|-------|-------|----------| -| 401 on valid requests | Token expired | Automatic refresh in Axios interceptor | -| Infinite refresh loop | Refresh token also expired | Force logout and redirect to login | -| 403 despite logged in | Missing permission | Check user role and permissions in database | -| Guest access not working | Setting disabled | Verify `auth.guest_access_enabled` in system_settings | -| Admin gets 503 in maintenance | Unauthenticated request | Use authenticated `api` client, not raw `fetch()` | +| Issue | Cause | Solution | +| ----------------------------- | -------------------------- | ----------------------------------------------------- | +| 401 on valid requests | Token expired | Automatic refresh in Axios interceptor | +| Infinite refresh loop | Refresh token also expired | Force logout and redirect to login | +| 403 despite logged in | Missing permission | Check user role and permissions in database | +| Guest access not working | Setting disabled | Verify `auth.guest_access_enabled` in system_settings | +| Admin gets 503 in maintenance | Unauthenticated request | Use authenticated `api` client, not raw `fetch()` | diff --git a/docs/02-architecture/data-flow-diagrams.md b/docs/02-architecture/data-flow-diagrams.md index 02d29c1..fdebe8f 100644 --- a/docs/02-architecture/data-flow-diagrams.md +++ b/docs/02-architecture/data-flow-diagrams.md @@ -40,10 +40,12 @@ sequenceDiagram ``` **Data Transformation**: + - Frontend state: `{ search: "mario", platform: "Flash", page: 1, limit: 50 }` - URL params: `?search=mario&platform=Flash&page=1&limit=50` - Backend validation: Zod schema validation -- SQL: `SELECT * FROM game WHERE platformName = 'Flash' AND title LIKE '%mario%' ... LIMIT 50 OFFSET 0` +- SQL: + `SELECT * FROM game WHERE platformName = 'Flash' AND title LIKE '%mario%' ... LIMIT 50 OFFSET 0` ## 2. Game Detail View Flow @@ -132,18 +134,15 @@ sequenceDiagram ``` **Filter Options Response**: + ```json { "platforms": [ { "name": "Flash", "count": 85000 }, { "name": "HTML5", "count": 15000 } ], - "developers": [ - { "name": "Developer A", "count": 500 } - ], - "tags": [ - { "name": "Action", "count": 15000 } - ], + "developers": [{ "name": "Developer A", "count": 500 }], + "tags": [{ "name": "Action", "count": 15000 }], "yearRange": { "min": 1995, "max": 2024 } } ``` @@ -251,18 +250,20 @@ sequenceDiagram ``` **Debounce Implementation**: + ```typescript const debouncedSearch = useMemo( - () => debounce((value: string) => { - onSearch(value); - }, 300), + () => + debounce((value: string) => { + onSearch(value); + }, 300), [onSearch] ); const handleChange = (e: React.ChangeEvent) => { const value = e.target.value; - setSearchValue(value); // Immediate UI update - debouncedSearch(value); // Debounced API call + setSearchValue(value); // Immediate UI update + debouncedSearch(value); // Debounced API call }; ``` @@ -293,12 +294,13 @@ sequenceDiagram ``` **Multi-Select SQL**: + ```typescript // Frontend: Array to string -const platformParam = selectedPlatforms.join(','); // "Flash,HTML5" +const platformParam = selectedPlatforms.join(','); // "Flash,HTML5" // Backend: Parse back -const platforms = query.platform?.split(',').filter(Boolean); // ["Flash", "HTML5"] +const platforms = query.platform?.split(',').filter(Boolean); // ["Flash", "HTML5"] // SQL: Dynamic placeholders if (platforms?.length > 0) { @@ -366,14 +368,15 @@ graph TB ``` **Stale-While-Revalidate**: + ```typescript useQuery({ queryKey: ['games', filters], queryFn: () => gamesApi.search(filters), - staleTime: 5 * 60 * 1000, // 5 min - fresh - cacheTime: 10 * 60 * 1000, // 10 min - in memory - refetchOnWindowFocus: true, // Refresh on tab focus - keepPreviousData: true // Show old while fetching new + staleTime: 5 * 60 * 1000, // 5 min - fresh + cacheTime: 10 * 60 * 1000, // 10 min - in memory + refetchOnWindowFocus: true, // Refresh on tab focus + keepPreviousData: true, // Show old while fetching new }); ``` diff --git a/docs/02-architecture/game-launch-flow.md b/docs/02-architecture/game-launch-flow.md index a01d129..90d7e5b 100644 --- a/docs/02-architecture/game-launch-flow.md +++ b/docs/02-architecture/game-launch-flow.md @@ -2,7 +2,9 @@ ## Overview -Game launch orchestrates multiple services: frontend requests launch data, backend mounts ZIPs, and frontend loads content directly from game-service into Ruffle or iframe. +Game launch orchestrates multiple services: frontend requests launch data, +backend mounts ZIPs, and frontend loads content directly from game-service into +Ruffle or iframe. ## Architecture @@ -102,6 +104,7 @@ sequenceDiagram ``` **Launch Data Response**: + ```typescript interface GameLaunchData { gameId: string; @@ -152,6 +155,7 @@ sequenceDiagram ``` **ZIP Manager Implementation**: + ```typescript class ZipManager { private mounts: Map = new Map(); @@ -307,6 +311,7 @@ graph TB ``` **Implementation**: + ```typescript app.get('*', async (req, res) => { try { @@ -319,15 +324,26 @@ app.get('*', async (req, res) => { const htdocsPath = join(config.htdocsPath, domain, path); if (fs.existsSync(htdocsPath)) { return res.sendFile(htdocsPath, { - headers: { 'Content-Type': getMimeType(htdocsPath), 'Access-Control-Allow-Origin': '*' } + headers: { + 'Content-Type': getMimeType(htdocsPath), + 'Access-Control-Allow-Origin': '*', + }, }); } // 2. Check game data directory - const gameDataPath = join(process.env.FLASHPOINT_PATH, 'Data', domain, path); + const gameDataPath = join( + process.env.FLASHPOINT_PATH, + 'Data', + domain, + path + ); if (fs.existsSync(gameDataPath)) { return res.sendFile(gameDataPath, { - headers: { 'Content-Type': getMimeType(gameDataPath), 'Access-Control-Allow-Origin': '*' } + headers: { + 'Content-Type': getMimeType(gameDataPath), + 'Access-Control-Allow-Origin': '*', + }, }); } @@ -336,7 +352,7 @@ app.get('*', async (req, res) => { if (zipFile) { return res.send(zipFile).set({ 'Content-Type': getMimeType(path), - 'Access-Control-Allow-Origin': '*' + 'Access-Control-Allow-Origin': '*', }); } @@ -344,11 +360,13 @@ app.get('*', async (req, res) => { for (const cdnBase of process.env.EXTERNAL_FALLBACK_URLS.split(',')) { try { const cdnUrl = `${cdnBase}/${domain}${path}`; - const response = await axios.get(cdnUrl, { responseType: 'arraybuffer' }); + const response = await axios.get(cdnUrl, { + responseType: 'arraybuffer', + }); await cacheFile(htdocsPath, response.data); return res.send(response.data).set({ 'Content-Type': response.headers['content-type'], - 'Access-Control-Allow-Origin': '*' + 'Access-Control-Allow-Origin': '*', }); } catch (cdnError) { continue; @@ -381,7 +399,7 @@ function getMimeType(filePath: string): string { '.mp3': 'audio/mpeg', '.mp4': 'video/mp4', '.pdf': 'application/pdf', - '.zip': 'application/zip' + '.zip': 'application/zip', }; return mimeTypes[ext] || 'application/octet-stream'; @@ -446,7 +464,8 @@ export const RufflePlayer: React.FC<{ gameUrl: string; gameTitle: string }> = ({ 2. Frontend: `GET /api/games/abc-123-def-456/launch` 3. Backend queries database, finds `presentOnDisk = 1` 4. Backend: `POST http://localhost:22501/mount` → Mounts ZIP -5. Backend returns: `contentUrl: http://localhost:22500/http://example.com/games/mario.swf` +5. Backend returns: + `contentUrl: http://localhost:22500/http://example.com/games/mario.swf` 6. Frontend creates Ruffle player with this URL 7. Ruffle: `GET http://localhost:22500/http://example.com/games/mario.swf` 8. Proxy server checks htdocs → game data → mounted ZIPs → **Found in ZIP!** diff --git a/docs/02-architecture/play-tracking-flow.md b/docs/02-architecture/play-tracking-flow.md index 46616c8..164716b 100644 --- a/docs/02-architecture/play-tracking-flow.md +++ b/docs/02-architecture/play-tracking-flow.md @@ -2,7 +2,8 @@ ## Overview -The play tracking system monitors user gameplay sessions, records statistics, and provides analytics. +The play tracking system monitors user gameplay sessions, records statistics, +and provides analytics. ## Architecture @@ -81,6 +82,7 @@ sequenceDiagram ``` **Database Schema**: + ```sql CREATE TABLE user_game_plays ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -100,8 +102,12 @@ CREATE INDEX idx_user_game_plays_game_id ON user_game_plays(game_id); ``` **Frontend Hook**: + ```typescript -export function usePlaySession(gameId: string | null, gameTitle: string | null) { +export function usePlaySession( + gameId: string | null, + gameTitle: string | null +) { const sessionIdRef = useRef(null); const previousGameIdRef = useRef(null); const hasStartedSessionRef = useRef(false); @@ -115,7 +121,7 @@ export function usePlaySession(gameId: string | null, gameTitle: string | null) }, onError: (error) => { hasStartedSessionRef.current = false; - } + }, }); useEffect(() => { @@ -147,7 +153,10 @@ export function usePlaySession(gameId: string | null, gameTitle: string | null) }; }, []); - return { sessionId: sessionIdRef.current, isTracking: !!sessionIdRef.current }; + return { + sessionId: sessionIdRef.current, + isTracking: !!sessionIdRef.current, + }; } ``` @@ -188,6 +197,7 @@ sequenceDiagram ``` **Backend Service**: + ```typescript async endPlaySession(sessionId: string): Promise { const session = UserDatabaseService.get( @@ -247,6 +257,7 @@ graph TB ``` **Database Schema**: + ```sql CREATE TABLE user_game_stats ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -263,6 +274,7 @@ CREATE TABLE user_game_stats ( ``` **Update Logic**: + ```typescript private async updateGameStats( userId: number, @@ -302,6 +314,7 @@ private async updateGameStats( ### Overall User Statistics **Database Schema**: + ```sql CREATE TABLE user_stats ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -355,6 +368,7 @@ private async updateFlashpointGameStats( ## 4. Statistics Retrieval **API Endpoints**: + ```http GET /api/play/stats GET /api/play/top-games?limit=10 @@ -363,6 +377,7 @@ GET /api/play/history?limit=50&offset=0 ``` **Response Examples**: + ```json { "userId": 1, @@ -376,7 +391,8 @@ GET /api/play/history?limit=50&offset=0 ## 5. Abandoned Session Cleanup -Runs every 6 hours. Sessions inactive for 24+ hours are marked as ended with 4-hour duration cap. +Runs every 6 hours. Sessions inactive for 24+ hours are marked as ended with +4-hour duration cap. ```mermaid sequenceDiagram @@ -401,6 +417,7 @@ sequenceDiagram ``` **Implementation**: + ```typescript async cleanupAbandonedSessions(): Promise { const abandoned = UserDatabaseService.all( @@ -432,6 +449,7 @@ async cleanupAbandonedSessions(): Promise { ``` **Scheduler Setup**: + ```typescript import { schedule } from 'node-cron'; @@ -445,6 +463,7 @@ schedule('0 */6 * * *', async () => { ## 6. Frontend Components **User Stats Panel**: + ```typescript export const UserStatsPanel = () => { const { data: stats, isLoading } = useUserStats(); @@ -468,6 +487,7 @@ export const UserStatsPanel = () => { ## Performance Optimization **Database Indexing**: + ```sql CREATE INDEX idx_user_game_plays_user_id ON user_game_plays(user_id); CREATE INDEX idx_user_game_plays_game_id ON user_game_plays(game_id); @@ -478,11 +498,12 @@ CREATE INDEX idx_user_game_stats_playtime ON user_game_stats(user_id, total_play **Query Optimization**: Use JOINs instead of N+1 queries. **Frontend Caching**: + ```typescript useQuery({ queryKey: ['playStats'], queryFn: () => playTrackingApi.getStats(), - staleTime: 5 * 60 * 1000 // Cache for 5 minutes + staleTime: 5 * 60 * 1000, // Cache for 5 minutes }); ``` @@ -492,4 +513,5 @@ useQuery({ **Data Export**: Allow users to export play history as CSV. -**Aggregation Verification**: Periodic verification job compares calculated stats with stored stats and recalculates on mismatch. +**Aggregation Verification**: Periodic verification job compares calculated +stats with stored stats and recalculates on mismatch. diff --git a/docs/02-architecture/service-communication.md b/docs/02-architecture/service-communication.md index e9645ec..95d6165 100644 --- a/docs/02-architecture/service-communication.md +++ b/docs/02-architecture/service-communication.md @@ -2,7 +2,8 @@ ## Overview -Flashpoint Web uses HTTP-based communication between three services. Frontend communicates with backend, which proxies game file requests to game-service. +Flashpoint Web uses HTTP-based communication between three services. Frontend +communicates with backend, which proxies game file requests to game-service. ## Communication Architecture @@ -49,10 +50,11 @@ graph TB **Protocol**: HTTP/1.1 with JSON, JWT Bearer authentication **API Client Configuration**: + ```typescript const api = axios.create({ baseURL: '/api', - headers: { 'Content-Type': 'application/json' } + headers: { 'Content-Type': 'application/json' }, }); // Request interceptor adds JWT @@ -116,6 +118,7 @@ PATCH /api/users/me/settings {theme_mode, primary_color} ``` **Error Response Format**: + ```json { "error": "Error message description", @@ -127,11 +130,13 @@ PATCH /api/users/me/settings {theme_mode, primary_color} ## Backend ↔ Game Service Communication Backend does NOT directly proxy game files. Instead: + 1. Returns launch URLs pointing to game service 2. Frontend loads content directly from game service 3. Backend mounts ZIPs when needed via internal API **Mount Game ZIP**: + ```http POST http://localhost:22501/mount { "gameId": "uuid", "zipPath": "D:/Flashpoint/Data/Games/A/uuid.zip" } @@ -139,6 +144,7 @@ Response: { "success": true, "mountPoint": "/gamedata/uuid" } ``` **Unmount Game ZIP**: + ```http POST http://localhost:22501/unmount { "gameId": "uuid" } @@ -170,6 +176,7 @@ sequenceDiagram **Request**: `GET /http://example.com/path/game.swf` **Fallback Chain**: + 1. Local htdocs: `D:/Flashpoint/Legacy/htdocs/example.com/path/game.swf` 2. Game data directory 3. Mounted ZIPs (via zip-manager) @@ -177,6 +184,7 @@ sequenceDiagram 5. Cache downloaded content locally **Response Headers**: + ``` Content-Type: application/x-shockwave-flash Access-Control-Allow-Origin: * @@ -204,14 +212,14 @@ export default defineConfig({ proxy: { '/api': { target: 'http://localhost:3100', - changeOrigin: true + changeOrigin: true, }, '/proxy': { target: 'http://localhost:22500', - changeOrigin: true - } - } - } + changeOrigin: true, + }, + }, + }, }); ``` @@ -229,8 +237,8 @@ await queryClient.invalidateQueries({ queryKey: ['playStats'] }); useQuery({ queryKey: ['games'], queryFn: fetchGames, - staleTime: 5 * 60 * 1000, // 5 minutes - cacheTime: 10 * 60 * 1000 // 10 minutes + staleTime: 5 * 60 * 1000, // 5 minutes + cacheTime: 10 * 60 * 1000, // 10 minutes }); ``` @@ -245,7 +253,7 @@ const mutation = useMutation({ queryClient.setQueryData(['playlist', playlistId], (old) => ({ ...old, - games: [...old.games, gameId] + games: [...old.games, gameId], })); return { previous }; @@ -255,45 +263,49 @@ const mutation = useMutation({ }, onSettled: () => { queryClient.invalidateQueries(['playlist', playlistId]); - } + }, }); ``` ## Rate Limiting **Backend**: + ```typescript const limiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100, - message: 'Too many requests' + message: 'Too many requests', }); const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5, - skipSuccessfulRequests: true + skipSuccessfulRequests: true, }); app.use('/api/', limiter); app.post('/api/auth/login', authLimiter, loginHandler); ``` -**Frontend**: TanStack Query deduplication prevents duplicate concurrent requests. +**Frontend**: TanStack Query deduplication prevents duplicate concurrent +requests. ## Error Handling **Frontend**: + ```typescript useQuery({ queryKey: ['games'], queryFn: fetchGames, retry: 3, - retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000) + retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), }); ``` **Backend**: + ```typescript router.get('/:id', async (req, res, next) => { try { @@ -317,37 +329,45 @@ app.use((err, req, res, next) => { ## Performance Optimization **Response Compression**: + ```typescript import compression from 'compression'; app.use(compression()); ``` -**Connection Pooling**: BetterSqlite3 uses single connection (optimized for SQLite). +**Connection Pooling**: BetterSqlite3 uses single connection (optimized for +SQLite). ## Security Considerations **CORS Configuration**: + ```typescript // Backend -app.use(cors({ - origin: process.env.DOMAIN || 'http://localhost:5173', - credentials: true, - allowedHeaders: ['Content-Type', 'Authorization'] -})); +app.use( + cors({ + origin: process.env.DOMAIN || 'http://localhost:5173', + credentials: true, + allowedHeaders: ['Content-Type', 'Authorization'], + }) +); // Game Service (wide open for cross-domain content) -app.use(cors({ - origin: '*', - methods: ['GET', 'HEAD', 'OPTIONS'] -})); +app.use( + cors({ + origin: '*', + methods: ['GET', 'HEAD', 'OPTIONS'], + }) +); ``` **Request Validation**: + ```typescript const searchQuerySchema = z.object({ search: z.string().optional(), page: z.coerce.number().int().min(1).default(1), - limit: z.coerce.number().int().min(1).max(100).default(50) + limit: z.coerce.number().int().min(1).max(100).default(50), }); const query = searchQuerySchema.parse(req.query); @@ -366,14 +386,18 @@ const game = DatabaseService.get(`SELECT * FROM game WHERE id = '${gameId}'`); ## Monitoring **Request Logging**: + ```typescript import morgan from 'morgan'; -app.use(morgan('combined', { - stream: { write: (message) => logger.info(message.trim()) } -})); +app.use( + morgan('combined', { + stream: { write: (message) => logger.info(message.trim()) }, + }) +); ``` **Activity Tracking**: + ```typescript export const logActivity = (action: string, targetType: string) => { return async (req, res, next) => { @@ -382,7 +406,7 @@ export const logActivity = (action: string, targetType: string) => { action, targetType, targetId: req.params.id, - ipAddress: req.ip + ipAddress: req.ip, }); next(); }; diff --git a/docs/02-architecture/system-architecture.md b/docs/02-architecture/system-architecture.md index eee44aa..00952e8 100644 --- a/docs/02-architecture/system-architecture.md +++ b/docs/02-architecture/system-architecture.md @@ -2,7 +2,9 @@ ## Overview -Flashpoint Web is a monorepo containing three independent microservices: frontend (React UI), backend (Express API), and game-service (game file proxy). Services communicate via HTTP with clean separation of concerns. +Flashpoint Web is a monorepo containing three independent microservices: +frontend (React UI), backend (Express API), and game-service (game file proxy). +Services communicate via HTTP with clean separation of concerns. ## Architecture Diagram @@ -92,15 +94,18 @@ graph TB **Purpose**: React single-page application for user interface -**Technology**: React 18, TypeScript, Vite, React Router, TanStack Query, Zustand, Tailwind CSS, Ruffle +**Technology**: React 18, TypeScript, Vite, React Router, TanStack Query, +Zustand, Tailwind CSS, Ruffle **State Management**: + - **Server State** (TanStack Query): Games, playlists, user data - **UI State** (Zustand): Sidebar, view modes, auth state - **URL State** (React Router): Search params, filters - **Local Storage**: Auth tokens, preferences -**Key Features**: Game browsing, search, filtering, play tracking, favorites, playlists +**Key Features**: Game browsing, search, filtering, play tracking, favorites, +playlists ### Backend Service (Port 3100) @@ -109,6 +114,7 @@ graph TB **Technology**: Express.js, TypeScript, BetterSqlite3, JWT, Bcrypt, Zod **Key Responsibilities**: + - Game metadata queries - User management and authentication - Role-based access control (RBAC) @@ -117,6 +123,7 @@ graph TB - Database hot-reloading for Flashpoint Launcher updates **Databases**: + - **flashpoint.sqlite**: Read-only with file watcher - **user.db**: Read-write with schema migrations @@ -127,6 +134,7 @@ graph TB **Purpose**: Game file serving and ZIP mounting **HTTP Proxy Server (22500)**: + - Serves legacy web content with fallback chain: 1. Local htdocs directory 2. Game data directory @@ -135,6 +143,7 @@ graph TB 5. Local cache **GameZip Server (22501)**: + - Mounts and streams ZIP archives - Zero-extraction design - LRU cache (max 100, 30-min TTL) @@ -178,6 +187,7 @@ graph LR ``` **Frontend → Backend**: + 1. User interaction triggers component action 2. Custom hook calls API client 3. API client adds JWT token via interceptor @@ -185,6 +195,7 @@ graph LR 5. Vite proxy forwards to Express (development) **Backend Processing**: + 1. Express route receives request 2. Authentication middleware verifies JWT 3. RBAC middleware checks permissions @@ -195,6 +206,7 @@ graph LR 8. Response sent back to client **Frontend State Update**: + 1. TanStack Query receives response 2. Cache updated with new data 3. Components re-render @@ -209,6 +221,7 @@ graph LR **Hot-Reload**: FileSystem watcher detects changes and reloads connection **Key Tables**: + - `game`: Game metadata (100,000+ entries) - `game_data`: Game file paths and launch commands - `platform`: Gaming platforms (Flash, HTML5, etc.) @@ -220,22 +233,26 @@ graph LR **Ownership**: Application-managed with migrations **Authentication & Authorization**: + - `users`: User accounts with hashed passwords (bcrypt) - `roles`: RBAC roles (admin, moderator, user, guest) - `role_permissions`: Role-permission mappings - `system_settings`: Global configuration **User Features**: + - `user_playlists`: User-created playlists - `user_favorites`: Favorited games - `user_settings`: User preferences **Play Tracking**: + - `user_game_plays`: Individual sessions - `user_game_stats`: Per-game aggregates - `user_stats`: Overall user stats **Activity Logging**: + - `activity_logs`: Audit trail ## Security Architecture @@ -266,11 +283,13 @@ sequenceDiagram ### Authorization **Role-Based Access Control**: + ``` User → Role → Permissions → Resources ``` **Default Roles**: + - `admin`: Full system access - `moderator`: Content management - `user`: Standard features @@ -296,6 +315,7 @@ D:/Flashpoint/ ``` **Access Patterns**: + - **Images/Logos**: Served by backend from file system - **Game Content**: Delegated to game-service proxy - **ZIPs**: Mounted by GameZip server on-demand @@ -317,11 +337,13 @@ All services run on localhost with SQLite databases. ### Scaling Strategies (Future) **Horizontal**: + - Frontend: Static hosting on CDN (Vercel, Netlify) - Backend: Multiple instances behind load balancer - Game Service: Region-specific replicas **Vertical**: + - flashpoint.sqlite read replicas - user.db: PostgreSQL/MySQL for better concurrency - Redis for session storage and caching @@ -341,13 +363,15 @@ All services run on localhost with SQLite databases. **Frontend**: Static build in `dist/`, minified, tree-shaken -**Backend**: Compiled TypeScript, INFO logging, rate limiting, environment-specific JWT secret +**Backend**: Compiled TypeScript, INFO logging, rate limiting, +environment-specific JWT secret **Game Service**: Compiled TypeScript, structured logging, optimized concurrency ## Error Handling **Frontend Error Boundaries**: + ```typescript }> @@ -355,9 +379,13 @@ All services run on localhost with SQLite databases. ``` **Backend**: + ```typescript class AppError extends Error { - constructor(public statusCode: number, message: string) {} + constructor( + public statusCode: number, + message: string + ) {} } app.use((err, req, res, next) => { @@ -373,11 +401,14 @@ app.use((err, req, res, next) => { ## Monitoring & Logging -**Activity Logging**: All significant user actions logged with userId, action, targetType, targetId, timestamp +**Activity Logging**: All significant user actions logged with userId, action, +targetType, targetId, timestamp -**Performance Metrics**: Play session durations, API response times, database query performance +**Performance Metrics**: Play session durations, API response times, database +query performance **Log Levels**: + - `DEBUG`: Development - `INFO`: Normal operations - `WARN`: Recoverable issues @@ -385,14 +416,21 @@ app.use((err, req, res, next) => { ## Technology Rationale -**TypeScript**: Type safety, IDE support, refactoring, shared types, reduced runtime errors +**TypeScript**: Type safety, IDE support, refactoring, shared types, reduced +runtime errors -**BetterSqlite3**: Synchronous API, performance, perfect for read-heavy workloads, WAL mode +**BetterSqlite3**: Synchronous API, performance, perfect for read-heavy +workloads, WAL mode -**TanStack Query**: Automatic caching, invalidation, background refetching, optimistic updates +**TanStack Query**: Automatic caching, invalidation, background refetching, +optimistic updates -**Monorepo**: Shared types, coordinated development, single deployment, consistent tooling +**Monorepo**: Shared types, coordinated development, single deployment, +consistent tooling ## Conclusion -Flashpoint Web balances simplicity with scalability, maintaining clean service separation while enabling efficient inter-service communication. The three-service design allows independent scaling and deployment while keeping the codebase maintainable and type-safe. +Flashpoint Web balances simplicity with scalability, maintaining clean service +separation while enabling efficient inter-service communication. The +three-service design allows independent scaling and deployment while keeping the +codebase maintainable and type-safe. diff --git a/docs/03-backend/README.md b/docs/03-backend/README.md index c18891f..082ab53 100644 --- a/docs/03-backend/README.md +++ b/docs/03-backend/README.md @@ -1,6 +1,9 @@ # Backend Service -The backend is a REST API built with Express and TypeScript that serves as the central hub for the Flashpoint Web application. It manages game metadata, user accounts, authentication, and coordinates with the separate game-service for content delivery. +The backend is a REST API built with Express and TypeScript that serves as the +central hub for the Flashpoint Web application. It manages game metadata, user +accounts, authentication, and coordinates with the separate game-service for +content delivery. ## Overview @@ -38,11 +41,14 @@ npm start # Run production build ## Environment Variables **Required:** -- `FLASHPOINT_PATH` - Path to Flashpoint installation (all other paths derived automatically) + +- `FLASHPOINT_PATH` - Path to Flashpoint installation (all other paths derived + automatically) - `GAME_SERVICE_HOST` - Game service hostname/IP (default: localhost) - `DOMAIN` - Frontend URL (default: http://localhost:5173) -See [configuration.md](./configuration.md) for complete environment variable documentation. +See [configuration.md](./configuration.md) for complete environment variable +documentation. ## Core Concepts @@ -76,11 +82,13 @@ See [configuration.md](./configuration.md) for complete environment variable doc ### Role-Based Access Control (RBAC) Three default roles: + - **Admin** (priority 100): Full access - **User** (priority 50): Can play games, manage playlists - **Guest** (priority 0): Read-only access -Permissions are assigned to roles, and users inherit permissions from their role. +Permissions are assigned to roles, and users inherit permissions from their +role. ## Project Structure @@ -103,6 +111,7 @@ backend/ ## API Routes Main route groups: + - `/api/auth` - Login, register, token refresh - `/api/users` - User management - `/api/roles` - Role and permission management @@ -112,12 +121,15 @@ Main route groups: - `/api/playlists` - User playlists - `/api/domains` - Domain management -See the [API Reference](../06-api-reference/README.md) for complete API documentation. +See the [API Reference](../06-api-reference/README.md) for complete API +documentation. ## Services -- [database-service.md](./services/database-service.md) - Flashpoint database connection with hot-reload -- [user-database-service.md](./services/user-database-service.md) - User database with migrations +- [database-service.md](./services/database-service.md) - Flashpoint database + connection with hot-reload +- [user-database-service.md](./services/user-database-service.md) - User + database with migrations - AuthService - Authentication and JWT management - UserService - User CRUD operations - GameService - Game metadata queries @@ -147,6 +159,7 @@ See the [API Reference](../06-api-reference/README.md) for complete API document ## Background Jobs **Abandoned Session Cleanup** + - Runs every 6 hours - Ends sessions older than 24 hours - Updates play statistics @@ -154,6 +167,7 @@ See the [API Reference](../06-api-reference/README.md) for complete API document ## Error Handling Centralized error middleware: + - `AppError` class for operational errors - Automatic HTTP status code mapping - Structured error responses @@ -162,6 +176,7 @@ Centralized error middleware: ## Logging Winston logger with: + - Console output with colors (development) - File logging (production) - Log levels: error, warn, info, debug @@ -201,16 +216,19 @@ npm test -- --coverage # Coverage report ## Troubleshooting ### Database Locked Error + - Close Flashpoint Launcher before starting backend - Ensure only one backend instance is running - Check file permissions on database files ### Authentication Failures + - Verify JWT_SECRET is set correctly - Check token expiration time - Ensure user is active in database ### Game Files Not Loading + - Verify game-service is running on port 22500/22501 - Check GAME_SERVICE_HOST in .env (default: localhost) - Ensure FLASHPOINT_PATH is correct @@ -230,6 +248,7 @@ NODE_ENV=production npm start ``` Requirements: + 1. Set `NODE_ENV=production` 2. Set secure `JWT_SECRET` 3. Configure proper `DOMAIN` @@ -240,6 +259,7 @@ Requirements: ## Performance Metrics Typical response times (p95): + - Game search: < 100ms - Game details: < 50ms - User login: < 200ms (bcrypt hashing) diff --git a/docs/03-backend/architecture.md b/docs/03-backend/architecture.md index 5223aad..05869cb 100644 --- a/docs/03-backend/architecture.md +++ b/docs/03-backend/architecture.md @@ -1,6 +1,7 @@ # Backend Architecture -Architectural patterns and design decisions for the Flashpoint Web backend service. +Architectural patterns and design decisions for the Flashpoint Web backend +service. ## Architecture Overview @@ -77,11 +78,14 @@ export class DatabaseService { Composable request processing; each middleware handles one concern. ```typescript -router.post('/users', - authenticate, // Verify JWT - requirePermission('users.create'), // Check permission - logActivity('create', 'users'), // Log activity - async (req, res) => { /* handle */ } +router.post( + '/users', + authenticate, // Verify JWT + requirePermission('users.create'), // Check permission + logActivity('create', 'users'), // Log activity + async (req, res) => { + /* handle */ + } ); ``` @@ -94,6 +98,7 @@ Abstract data access from business logic; centralized query logic. ### 1. Two-Database Architecture **Rationale:** + - Flashpoint database managed by external Launcher - Prevents conflicts from simultaneous writes - User data needs different schema and write access @@ -102,6 +107,7 @@ Abstract data access from business logic; centralized query logic. ### 2. Synchronous Database Access (better-sqlite3) **Rationale:** + - SQLite is single-threaded; async provides no benefit - Simpler, more readable code - Better performance for SQLite workloads @@ -110,17 +116,20 @@ Abstract data access from business logic; centralized query logic. ### 3. JWT-Based Authentication **Rationale:** + - Stateless authentication (no session storage) - Works across multiple backend instances - Mobile-friendly **Implementation:** + - Access token: 1 hour expiry - Refresh token: 30 days expiry, stored in DB ### 4. RBAC over ACL **Rationale:** + - Easier to manage permissions for groups of users - Standard pattern for most applications - Simpler permission checks @@ -129,18 +138,21 @@ Abstract data access from business logic; centralized query logic. ### 5. Activity Logging **Rationale:** + - Audit trail for security - User behavior analytics - Debugging user issues - Compliance requirements **Implementation:** + - Non-blocking (setImmediate) - Automatic cleanup (90-day retention) ### 6. Play Session Tracking **Rationale:** + - Detailed play history per user - Calculate accurate statistics - Support for session duration @@ -177,12 +189,14 @@ export class AppError extends Error { ### Connection Management **Flashpoint Database:** + - Single read-only connection per process - File watcher for hot-reload - Automatic reconnection on file change - Debounced reload (500ms) **User Database:** + - Single read/write connection per process - Foreign key enforcement enabled - Schema migrations on startup @@ -192,7 +206,7 @@ export class AppError extends Error { - Indexes on foreign keys and frequently queried columns - Pagination to prevent memory issues - Prepared statements for reusable queries -- Selective columns (avoid SELECT *) +- Selective columns (avoid SELECT \*) ### Transaction Strategy @@ -257,10 +271,12 @@ transaction(); // Execute atomically ### Caching Strategy **Currently Implemented:** + - Permission caching in-memory (5-minute TTL) - 90%+ reduction in permission queries **Future Recommendations:** + - Redis for distributed session storage - Cache game metadata queries - Cache filter options (platforms, tags) @@ -270,6 +286,7 @@ transaction(); // Execute atomically ### Logging Winston logger with structured logging: + ```typescript logger.info('User logged in', { userId, username, ipAddress }); logger.error('Database error', { error: err.message }); @@ -278,6 +295,7 @@ logger.error('Database error', { error: err.message }); ### Activity Logs Database-backed activity logging: + - All user actions logged - IP address and user agent captured - Queryable for analytics @@ -286,6 +304,7 @@ Database-backed activity logging: ### Health Checks `/health` endpoint returns: + - Server status - Database connection status - Flashpoint path @@ -322,24 +341,28 @@ Database-backed activity logging: ## Best Practices ### Service Methods + - Return typed data (use TypeScript interfaces) - Throw `AppError` for business logic errors - Keep methods focused and single-purpose - Use transactions for multi-step operations ### Route Handlers + - Validate input early - Use middleware for auth/permissions - Handle errors with try/catch - Return consistent JSON structure ### Database Queries + - Use parameterized queries (prevent SQL injection) - Add indexes for frequently queried columns - Use LIMIT for potentially large results - Prefer prepared statements for reusable queries ### Error Handling + - Use `AppError` for expected errors - Log unexpected errors with full stack trace - Never expose internal errors to clients @@ -348,11 +371,13 @@ Database-backed activity logging: ## Testing Strategy ### Unit Tests + - Test individual service methods - Mock database services - Focus on business logic ### Integration Tests + - Test route handlers end-to-end - Use test database - Verify HTTP responses diff --git a/docs/03-backend/configuration.md b/docs/03-backend/configuration.md index 7e5aca0..d184de7 100644 --- a/docs/03-backend/configuration.md +++ b/docs/03-backend/configuration.md @@ -1,6 +1,7 @@ # Backend Configuration -This document describes all environment variables and configuration options for the Flashpoint Web backend service. +This document describes all environment variables and configuration options for +the Flashpoint Web backend service. ## Configuration File @@ -40,6 +41,7 @@ export const config = { ### Server Configuration #### `NODE_ENV` + - **Type**: string - **Default**: `development` - **Options**: `development`, `production`, `test` @@ -50,6 +52,7 @@ export const config = { ``` #### `PORT` + - **Type**: number - **Default**: `3100` - **Description**: HTTP server port @@ -59,6 +62,7 @@ export const config = { ``` #### `HOST` + - **Type**: string - **Default**: `0.0.0.0` - **Description**: Server bind address @@ -71,6 +75,7 @@ export const config = { ### Flashpoint Paths #### `FLASHPOINT_PATH` + - **Type**: string - **Default**: `D:/Flashpoint` - **Required**: Yes @@ -92,39 +97,54 @@ All other Flashpoint paths are automatically derived from `FLASHPOINT_PATH`: - Playlists: `${FLASHPOINT_PATH}/Data/Playlists` - Games: `${FLASHPOINT_PATH}/Data/Games` -You do not need to set environment variables for these paths - they are calculated automatically from `FLASHPOINT_PATH`. +You do not need to set environment variables for these paths - they are +calculated automatically from `FLASHPOINT_PATH`. ### Flashpoint Edition Auto-Detection -The Flashpoint edition is **automatically detected** from `${FLASHPOINT_PATH}/version.txt` on server startup. No environment variable is needed. +The Flashpoint edition is **automatically detected** from +`${FLASHPOINT_PATH}/version.txt` on server startup. No environment variable is +needed. **How it works:** -1. On startup, `backend/src/config.ts` reads `version.txt` from the Flashpoint installation directory +1. On startup, `backend/src/config.ts` reads `version.txt` from the Flashpoint + installation directory 2. The file content is parsed for "Infinity" or "Ultimate" (case-insensitive) -3. The detected edition and full version string are stored in the `config` object: +3. The detected edition and full version string are stored in the `config` + object: - `config.flashpointEdition` — `"infinity"` or `"ultimate"` - - `config.flashpointVersionString` — Full version string (e.g., `"Flashpoint 14.0.3 Infinity - Kingfisher"`) -4. Backend services (GameService, MetadataSyncService, MetadataUpdateService) read `config` directly -5. The frontend receives edition/version via the `/api/settings/public` endpoint (injected from `config`, not stored in DB) + - `config.flashpointVersionString` — Full version string (e.g., + `"Flashpoint 14.0.3 Infinity - Kingfisher"`) +4. Backend services (GameService, MetadataSyncService, MetadataUpdateService) + read `config` directly +5. The frontend receives edition/version via the `/api/settings/public` endpoint + (injected from `config`, not stored in DB) 6. If `version.txt` is missing or unparseable, defaults to `"infinity"` **Example version.txt contents:** + - Infinity: `Flashpoint 14.0.3 Infinity - Kingfisher` - Ultimate: `Flashpoint 14 Ultimate - Kingfisher` **Edition differences:** -- **Infinity**: Supports metadata sync from FPFSS, game table includes `logoPath`/`screenshotPath` columns -- **Ultimate**: No metadata sync, game table lacks `logoPath`/`screenshotPath` columns -The edition is displayed as read-only information in the Settings UI (not user-configurable). +- **Infinity**: Supports metadata sync from FPFSS, game table includes + `logoPath`/`screenshotPath` columns +- **Ultimate**: No metadata sync, game table lacks `logoPath`/`screenshotPath` + columns + +The edition is displayed as read-only information in the Settings UI (not +user-configurable). ### Game Service Configuration #### `GAME_SERVICE_HOST` + - **Type**: string - **Default**: `localhost` -- **Description**: Hostname/IP of game-service for constructing proxy and GameZip URLs +- **Description**: Hostname/IP of game-service for constructing proxy and + GameZip URLs - **Usage**: ```bash GAME_SERVICE_HOST=localhost # Development @@ -133,6 +153,7 @@ The edition is displayed as read-only information in the Settings UI (not user-c ``` The proxy and GameZip server URLs are automatically constructed as: + - Proxy: `http://${GAME_SERVICE_HOST}:22500` - GameZip: `http://${GAME_SERVICE_HOST}:22501` @@ -140,9 +161,11 @@ The proxy and GameZip server URLs are automatically constructed as: #### Image CDN URLs (Automatic) -Image CDN URLs are automatically read from Flashpoint's preferences file. No configuration required. +Image CDN URLs are automatically read from Flashpoint's preferences file. No +configuration required. -- **Source**: Flashpoint preferences (`onDemandBaseUrl` from `preferences.json` or `.preferences.defaults.json`) +- **Source**: Flashpoint preferences (`onDemandBaseUrl` from `preferences.json` + or `.preferences.defaults.json`) - **Fallback**: If preferences are unavailable, defaults to: - `https://infinity.flashpointarchive.org/Flashpoint/Data/Images` - `https://infinity.unstable.life/Flashpoint/Data/Images` @@ -150,6 +173,7 @@ Image CDN URLs are automatically read from Flashpoint's preferences file. No con ### Database Configuration #### `USER_DB_PATH` + - **Type**: string - **Default**: `./user.db` - **Description**: Path to user database file @@ -162,6 +186,7 @@ Image CDN URLs are automatically read from Flashpoint's preferences file. No con ### Security Configuration #### `JWT_SECRET` + - **Type**: string - **Default**: `change-in-production-use-long-random-string` - **Required**: Yes (in production) @@ -178,6 +203,7 @@ Image CDN URLs are automatically read from Flashpoint's preferences file. No con ``` #### `JWT_EXPIRES_IN` + - **Type**: string - **Default**: `1h` - **Description**: Access token expiration time @@ -190,6 +216,7 @@ Image CDN URLs are automatically read from Flashpoint's preferences file. No con ``` #### `BCRYPT_SALT_ROUNDS` + - **Type**: number - **Default**: `10` - **Description**: Number of bcrypt hashing rounds @@ -203,6 +230,7 @@ Image CDN URLs are automatically read from Flashpoint's preferences file. No con ### CORS Configuration #### `DOMAIN` + - **Type**: string - **Default**: `http://localhost:5173` - **Required**: Yes @@ -216,6 +244,7 @@ Image CDN URLs are automatically read from Flashpoint's preferences file. No con ### Rate Limiting #### `RATE_LIMIT_WINDOW_MS` + - **Type**: number - **Default**: `60000` (1 minute) - **Description**: Rate limit window in milliseconds @@ -225,6 +254,7 @@ Image CDN URLs are automatically read from Flashpoint's preferences file. No con ``` #### `RATE_LIMIT_MAX_REQUESTS` + - **Type**: number - **Default**: `100` - **Description**: Maximum requests per window @@ -237,6 +267,7 @@ Image CDN URLs are automatically read from Flashpoint's preferences file. No con ### Logging Configuration #### `LOG_LEVEL` + - **Type**: string - **Default**: `info` - **Options**: `error`, `warn`, `info`, `debug` @@ -249,15 +280,18 @@ Image CDN URLs are automatically read from Flashpoint's preferences file. No con ``` #### `LOG_FILE` + - **Type**: string - **Default**: undefined (console only) -- **Description**: Path to log file for persistent logging. When set, logs are written to both console and file. +- **Description**: Path to log file for persistent logging. When set, logs are + written to both console and file. - **Features**: - Automatic log rotation (10MB max file size) - Keeps last 5 log files - JSON format for easy parsing - Directory created automatically - **Usage**: + ```bash # File logging enabled LOG_FILE=/var/log/flashpoint-backend.log @@ -377,9 +411,10 @@ Configuration is loaded in this order: 3. **Default values** in `config.ts` Example: + ```typescript // PORT comes from: process.env.PORT → .env file → default '3100' -port: parseInt(process.env.PORT || '3100', 10) +port: parseInt(process.env.PORT || '3100', 10); ``` ## Security Best Practices @@ -399,17 +434,20 @@ port: parseInt(process.env.PORT || '3100', 10) ### Secret Management **Development**: + - Use `.env` file - Add `.env` to `.gitignore` - Share `.env.example` (without secrets) **Production**: + - Use environment variables (not `.env` file) - Use secret management service (AWS Secrets Manager, Azure Key Vault, etc.) - Rotate secrets regularly - Never commit secrets to git **Docker**: + ```bash # Pass secrets via environment docker run -e JWT_SECRET=$JWT_SECRET -e DOMAIN=$DOMAIN ... @@ -425,13 +463,16 @@ The backend validates configuration on startup: ```typescript // Checks performed in DatabaseService.initialize() if (!fs.existsSync(config.flashpointDbPath)) { - throw new Error(`Flashpoint database not found at: ${config.flashpointDbPath}`); + throw new Error( + `Flashpoint database not found at: ${config.flashpointDbPath}` + ); } ``` ### Common Configuration Errors **Database not found**: + ``` Error: Flashpoint database not found at: D:/Flashpoint/Data/flashpoint.sqlite Solution: Verify FLASHPOINT_PATH environment variable points to valid Flashpoint installation @@ -439,12 +480,14 @@ Solution: Verify FLASHPOINT_PATH environment variable points to valid Flashpoint ``` **Port already in use**: + ``` Error: listen EADDRINUSE: address already in use :::3100 Solution: Change PORT or stop other process using port 3100 ``` **CORS error**: + ``` Access to fetch at 'http://localhost:3100/api/games' from origin 'http://localhost:5174' has been blocked by CORS policy @@ -452,6 +495,7 @@ Solution: Set DOMAIN=http://localhost:5174 ``` **JWT verification failed**: + ``` Error: Invalid or expired token Solution: Ensure frontend and backend use same JWT_SECRET diff --git a/docs/03-backend/database/schema.md b/docs/03-backend/database/schema.md index 24d5520..4b5c774 100644 --- a/docs/03-backend/database/schema.md +++ b/docs/03-backend/database/schema.md @@ -15,7 +15,9 @@ The backend uses two separate SQLite databases: ### Purpose -Contains the official Flashpoint Archive game metadata. This database is managed by the Flashpoint Launcher and should be treated as read-only (except for play statistics). +Contains the official Flashpoint Archive game metadata. This database is managed +by the Flashpoint Launcher and should be treated as read-only (except for play +statistics). ### Main Tables @@ -67,6 +69,7 @@ CREATE INDEX idx_game_library ON game(library); ``` **Key Fields**: + - `id`: Unique game identifier (UUID) - `platformName`: Platform name (Flash, HTML5, Shockwave, etc.) - `library`: Game library (arcade, theatre) @@ -146,7 +149,9 @@ CREATE INDEX idx_game_data_gameId ON game_data(gameId); ``` **Key Fields**: -- `presentOnDisk`: Download status (NULL = no file needed, 0 = needs download, 1 = available) + +- `presentOnDisk`: Download status (NULL = no file needed, 0 = needs download, 1 + = available) - `launchCommand`: Preferred launch command - `path`: File path for game content - `parameters`: URL parameters to append @@ -192,7 +197,8 @@ CREATE TABLE playlist_game ( ### Purpose -Stores application-specific data: user accounts, authentication, roles, permissions, play tracking, and settings. +Stores application-specific data: user accounts, authentication, roles, +permissions, play tracking, and settings. ### Authentication Tables @@ -221,6 +227,7 @@ CREATE INDEX idx_users_is_active ON users(is_active); ``` **Default Users**: + - Username: `admin`, Password: `admin123` (created on first run) --- @@ -241,6 +248,7 @@ CREATE TABLE roles ( ``` **Default Roles**: + - `admin` (id=1, priority=100): Full access - `user` (id=2, priority=50): Standard access - `guest` (id=3, priority=0): Read-only access @@ -265,6 +273,7 @@ CREATE INDEX idx_permissions_resource_action ON permissions(resource, action); ``` **Default Permissions**: + - `games.read`, `games.play`, `games.download` - `playlists.read`, `playlists.create`, `playlists.update`, `playlists.delete` - `users.read`, `users.create`, `users.update`, `users.delete` @@ -492,6 +501,7 @@ CREATE INDEX idx_user_settings_key ON user_settings(setting_key); ``` **Common Settings**: + - `theme_mode`: "light" | "dark" | "system" - `primary_color`: Color name (e.g., "blue", "green") - `sidebar_collapsed`: "true" | "false" @@ -509,7 +519,8 @@ user_game_plays.game_id -> game.id (Flashpoint DB) user_game_stats.game_id -> game.id (Flashpoint DB) ``` -These are soft foreign keys (not enforced by SQLite) and must be maintained by application logic. +These are soft foreign keys (not enforced by SQLite) and must be maintained by +application logic. ### Permission Hierarchy @@ -544,6 +555,7 @@ All foreign keys and frequently queried columns are indexed for performance: ## Data Types SQLite TEXT fields store: + - **Dates/Times**: ISO 8601 format (`YYYY-MM-DDTHH:MM:SS.sssZ`) - **Booleans**: 0 (false) or 1 (true) - **JSON**: Stringified JSON objects in `details` columns @@ -555,9 +567,11 @@ SQLite TEXT fields store: See [migrations.md](./migrations.md) for migration system documentation. Applied migrations: + 1. `001_user-schema.sql` - Initial user database schema 2. `002_create-user-settings.sql` - Add user_settings table -3. `003_drop-legacy-theme-columns.sql` - Remove old theme columns from users table +3. `003_drop-legacy-theme-columns.sql` - Remove old theme columns from users + table --- @@ -602,7 +616,9 @@ LIMIT 10; ## Related Documentation -- [UserDatabaseService](../services/user-database-service.md) - Database access service -- [DatabaseService](../services/database-service.md) - Flashpoint database service +- [UserDatabaseService](../services/user-database-service.md) - Database access + service +- [DatabaseService](../services/database-service.md) - Flashpoint database + service - [Migrations](./migrations.md) - Migration system - [Data Models](./data-models.md) - TypeScript types diff --git a/docs/03-backend/services/database-service.md b/docs/03-backend/services/database-service.md index f36fe6c..24c28c2 100644 --- a/docs/03-backend/services/database-service.md +++ b/docs/03-backend/services/database-service.md @@ -1,10 +1,12 @@ # DatabaseService -Manages connection to the Flashpoint game metadata database (flashpoint.sqlite) with automatic hot-reload when the database file changes. +Manages connection to the Flashpoint game metadata database (flashpoint.sqlite) +with automatic hot-reload when the database file changes. ## Overview -Implements singleton pattern for database access with file watching and hot-reload. +Implements singleton pattern for database access with file watching and +hot-reload. ## Key Features @@ -12,7 +14,8 @@ Implements singleton pattern for database access with file watching and hot-relo - **File watching**: Detects when Flashpoint Launcher updates the database - **Hot-reload**: Automatically reconnects when database changes - **Read/Write access**: Allows play statistics updates -- **Synchronous API**: Uses better-sqlite3 for better performance than async wrappers +- **Synchronous API**: Uses better-sqlite3 for better performance than async + wrappers ## Public API @@ -44,10 +47,7 @@ const result = db.prepare('SELECT * FROM game WHERE id = ?').get(gameId); Execute a query and return the first result row. ```typescript -const game = DatabaseService.get( - 'SELECT * FROM game WHERE id = ?', - [gameId] -); +const game = DatabaseService.get('SELECT * FROM game WHERE id = ?', [gameId]); ``` **Returns**: First result row or null if no results @@ -153,7 +153,9 @@ DatabaseService.run( Main tables in Flashpoint database: ### game + Main game metadata table: + - `id` (TEXT PRIMARY KEY) - UUID - `title` (TEXT) - Game title - `developer` (TEXT) - Developer name @@ -168,12 +170,15 @@ Main game metadata table: - `lastPlayed` (TEXT) - Last played timestamp ### platform + Platform definitions with id and name. ### tag + Tag definitions with id, name, and category. ### game_tags_tag + Many-to-many relationship between games and tags. ## Error Handling @@ -208,15 +213,18 @@ this.watcher = fs.watch(config.flashpointDbPath, (eventType) => { }); ``` -**Debouncing**: Waits 500ms after last change to prevent excessive reloads during Launcher operations. +**Debouncing**: Waits 500ms after last change to prevent excessive reloads +during Launcher operations. ### Edge Cases -**Launcher Lock**: When Flashpoint Launcher is updating, reload waits 500ms then retries if lock still present. +**Launcher Lock**: When Flashpoint Launcher is updating, reload waits 500ms then +retries if lock still present. **Rapid Changes**: Multiple rapid changes debounce to single reload. -**File Deletion**: If database is deleted, error logged and service remains in error state until file restored. +**File Deletion**: If database is deleted, error logged and service remains in +error state until file restored. ## Performance @@ -224,7 +232,7 @@ this.watcher = fs.watch(config.flashpointDbPath, (eventType) => { - Use indexed columns in WHERE clauses - Limit result sets with LIMIT -- Avoid SELECT * for large tables +- Avoid SELECT \* for large tables - Use prepared statements (cached by better-sqlite3) ### Connection Reuse @@ -246,6 +254,7 @@ this.watcher = fs.watch(config.flashpointDbPath, (eventType) => { **Symptom**: `SQLITE_BUSY: database is locked` **Solutions**: + - Close Flashpoint Launcher - Wait for operations to complete - Use shorter transactions @@ -255,6 +264,7 @@ this.watcher = fs.watch(config.flashpointDbPath, (eventType) => { **Symptom**: `Flashpoint database not found at: ` **Solutions**: + - Verify path in .env - Check Flashpoint installation - Verify file permissions @@ -264,6 +274,7 @@ this.watcher = fs.watch(config.flashpointDbPath, (eventType) => { **Symptom**: Database changes not reflected **Solutions**: + - Check logs for watcher start message - Wait 500ms+ after change - Manually call `reload()` diff --git a/docs/03-backend/services/permission-cache-service.md b/docs/03-backend/services/permission-cache-service.md index a5bf8d2..7b33c04 100644 --- a/docs/03-backend/services/permission-cache-service.md +++ b/docs/03-backend/services/permission-cache-service.md @@ -2,27 +2,33 @@ ## Overview -The PermissionCache service provides an in-memory caching layer for user and role permissions, significantly reducing database queries and improving authorization performance. +The PermissionCache service provides an in-memory caching layer for user and +role permissions, significantly reducing database queries and improving +authorization performance. **Location:** `backend/src/services/PermissionCache.ts` **Type:** Singleton service (static methods) -**Purpose:** Cache user permissions to avoid repeated database queries during authorization checks +**Purpose:** Cache user permissions to avoid repeated database queries during +authorization checks ## Performance Impact **Before Caching:** + - Every authenticated request queries database for permissions - ~50-100ms per permission check (includes database query) - High database load under concurrent requests **After Caching:** + - 90%+ of permission checks served from cache - ~1-5ms per permission check (in-memory lookup) - Significantly reduced database load **Example Load:** + - 100 requests/second × 2 permission checks/request = 200 DB queries/second - With caching: ~20 DB queries/second (90% cache hit rate) - **10x reduction in database queries** @@ -34,7 +40,7 @@ The PermissionCache service provides an in-memory caching layer for user and rol ```typescript interface CacheEntry { data: T; - expiresAt: number; // Unix timestamp + expiresAt: number; // Unix timestamp } class PermissionCache { @@ -52,11 +58,13 @@ class PermissionCache { ### TTL (Time To Live) **User Permissions:** + - TTL: 5 minutes (300,000 ms) - Rationale: Balance between performance and permission update latency - Trade-off: Permission changes may take up to 5 minutes to propagate **Role Permissions:** + - TTL: 10 minutes (600,000 ms) - Rationale: Roles change less frequently than user-role assignments - Trade-off: Role permission changes may take up to 10 minutes to propagate @@ -86,13 +94,16 @@ class PermissionCache { Get user permissions from cache. **Parameters:** + - `userId` (number): User ID to lookup **Returns:** + - `string[]`: Array of permission strings if cache hit - `null`: If cache miss or entry expired **Example:** + ```typescript const cached = PermissionCache.getUserPermissions(5); if (cached) { @@ -111,14 +122,17 @@ if (cached) { Store user permissions in cache. **Parameters:** + - `userId` (number): User ID - `permissions` (string[]): Array of permission strings **Side Effects:** + - Creates cache entry with 5-minute TTL - Logs debug message to console **Example:** + ```typescript const permissions = ['games.read', 'games.play']; PermissionCache.setUserPermissions(5, permissions); @@ -129,14 +143,17 @@ PermissionCache.setUserPermissions(5, permissions); Remove user's permission cache entry. **Parameters:** + - `userId` (number): User ID to invalidate **Use Cases:** + - User role changed - User permissions updated - Manual cache invalidation **Example:** + ```typescript // After changing user role await userService.updateUserRole(5, 2); @@ -148,13 +165,16 @@ PermissionCache.invalidateUser(5); Remove role's permission cache entry. **Parameters:** + - `roleId` (number): Role ID to invalidate **Use Cases:** + - Role permissions updated - Manual cache invalidation **Example:** + ```typescript // After updating role permissions await roleService.updateRolePermissions(2, [1, 2, 3]); @@ -166,12 +186,14 @@ PermissionCache.invalidateRole(2); Clear entire cache (both user and role caches). **Use Cases:** + - Bulk permission changes - System maintenance - Testing/debugging - Admin-initiated clear via API **Example:** + ```typescript PermissionCache.clearAll(); logger.info('All permission caches cleared'); @@ -182,6 +204,7 @@ logger.info('All permission caches cleared'); Get cache statistics. **Returns:** + ```typescript { userCacheSize: number, // Number of cached users @@ -191,6 +214,7 @@ Get cache statistics. ``` **Example:** + ```typescript const stats = PermissionCache.getStats(); console.log(`Cache contains ${stats.userCacheSize} users`); @@ -201,11 +225,13 @@ console.log(`Cache contains ${stats.userCacheSize} users`); Start automatic cleanup interval (called on server start). **Behavior:** + - Runs cleanup every 5 minutes - Idempotent (won't start multiple intervals) - Logs startup message **Example:** + ```typescript // In server.ts await PermissionCache.startCleanup(); @@ -216,11 +242,13 @@ await PermissionCache.startCleanup(); Stop automatic cleanup interval (called on server shutdown). **Behavior:** + - Clears interval if running - Idempotent (safe to call multiple times) - Logs shutdown message **Example:** + ```typescript // In graceful shutdown process.on('SIGTERM', () => { @@ -321,11 +349,12 @@ async function startServer() { ## Cache Management Endpoints -### GET /_cache/permissions/stats +### GET /\_cache/permissions/stats **Admin only** - Get cache statistics. **Response:** + ```json { "userCacheSize": 42, @@ -343,11 +372,12 @@ async function startServer() { } ``` -### POST /_cache/permissions/clear +### POST /\_cache/permissions/clear **Admin only** - Clear permission cache. **Request Body:** + ```json { "type": "user", @@ -356,6 +386,7 @@ async function startServer() { ``` **Options:** + - `{ "type": "user", "id": 5 }` - Clear user 5's cache - `{ "type": "role", "id": 2 }` - Clear role 2's cache - `{ "type": "all" }` - Clear all caches @@ -365,17 +396,20 @@ async function startServer() { ### Logging **Cache Hits:** + ``` [PermissionCache] Cache HIT for user 5 ``` **Cache Invalidation:** + ``` [PermissionCache] Invalidated cache for user 5 [PermissionCache] Invalidated cache for role 2 ``` **Cleanup:** + ``` [PermissionCache] Cleanup interval started [PermissionCache] Cleanup removed 15 expired entries @@ -385,12 +419,14 @@ async function startServer() { ### Metrics to Monitor **Cache Hit Rate:** + ```typescript const hitRate = (cacheHits / totalRequests) * 100; // Target: >90% ``` **Cache Size:** + ```typescript const stats = PermissionCache.getStats(); // Monitor for unbounded growth @@ -398,6 +434,7 @@ const stats = PermissionCache.getStats(); ``` **Permission Lookup Time:** + ```typescript // Before caching: 50-100ms // After caching: 1-5ms @@ -409,12 +446,14 @@ const stats = PermissionCache.getStats(); ### Cache Invalidation **✅ DO:** + - Invalidate user cache when user role changes - Invalidate role cache when role permissions change - Clear all caches after bulk permission updates - Use manual invalidation for critical changes **❌ DON'T:** + - Rely on cache for real-time permission revocation (up to 5-minute delay) - Skip invalidation after permission changes - Invalidate unnecessarily (wastes CPU) @@ -422,6 +461,7 @@ const stats = PermissionCache.getStats(); ### Performance Tuning **Adjust TTL:** + ```typescript // Lower TTL for stricter security (more DB queries) private static readonly USER_PERMISSIONS_TTL = 1 * 60 * 1000; // 1 minute @@ -431,6 +471,7 @@ private static readonly USER_PERMISSIONS_TTL = 15 * 60 * 1000; // 15 minutes ``` **Cleanup Interval:** + ```typescript // More frequent cleanup (lower memory, higher CPU) setInterval(() => this.cleanup(), 1 * 60 * 1000); // 1 minute @@ -442,16 +483,19 @@ setInterval(() => this.cleanup(), 10 * 60 * 1000); // 10 minutes ### Security Considerations **Cache Timing:** + - Permission changes may take up to 5 minutes to propagate - For immediate revocation, manually clear cache - Critical operations should clear cache automatically **Cache Poisoning:** + - Cache only stores permission strings (low risk) - No user input stored in cache - Cache automatically expires **Memory Limits:** + - In-memory cache grows with user count - Automatic cleanup prevents unbounded growth - Monitor cache size in production @@ -463,10 +507,12 @@ setInterval(() => this.cleanup(), 10 * 60 * 1000); // 10 minutes **Symptom:** User's permissions don't reflect recent changes **Causes:** + 1. Cache not invalidated after permission change 2. TTL hasn't expired yet (up to 5 minutes) **Solutions:** + ```bash # Manually clear user's cache curl -X POST http://localhost:3100/_cache/permissions/clear \ @@ -484,11 +530,13 @@ curl -X POST http://localhost:3100/_cache/permissions/clear \ **Symptom:** Server memory usage increasing over time **Causes:** + 1. Cleanup interval not running 2. Too many unique users 3. TTL too long **Solutions:** + ```typescript // Check cleanup is running PermissionCache.getStats(); // Should show reasonable size @@ -505,11 +553,13 @@ private static readonly USER_PERMISSIONS_TTL = 2 * 60 * 1000; // 2 minutes **Symptom:** Low cache hit rate (<50%) **Causes:** + 1. TTL too short 2. Frequent permission changes 3. Users not making repeated requests **Solutions:** + ```typescript // Increase TTL private static readonly USER_PERMISSIONS_TTL = 10 * 60 * 1000; // 10 minutes diff --git a/docs/03-backend/services/user-database-service.md b/docs/03-backend/services/user-database-service.md index b4c4f28..2605911 100644 --- a/docs/03-backend/services/user-database-service.md +++ b/docs/03-backend/services/user-database-service.md @@ -1,6 +1,8 @@ # UserDatabaseService -`UserDatabaseService` manages the connection to the user database (user.db). It provides a singleton interface for accessing user data, authentication, roles, permissions, and play tracking. +`UserDatabaseService` manages the connection to the user database (user.db). It +provides a singleton interface for accessing user data, authentication, roles, +permissions, and play tracking. ## Location @@ -8,7 +10,9 @@ ## Overview -The UserDatabaseService connects to the application-specific SQLite database for storing user accounts, sessions, and application data. Unlike DatabaseService (read-only Flashpoint data), this service has full read/write access. +The UserDatabaseService connects to the application-specific SQLite database for +storing user accounts, sessions, and application data. Unlike DatabaseService +(read-only Flashpoint data), this service has full read/write access. ## Key Features @@ -154,10 +158,9 @@ const db = UserDatabaseService.getDatabase(); Execute query and return first row. ```typescript -const user = UserDatabaseService.get( - 'SELECT * FROM users WHERE username = ?', - ['admin'] -); +const user = UserDatabaseService.get('SELECT * FROM users WHERE username = ?', [ + 'admin', +]); ``` **Returns**: First result row or null @@ -199,6 +202,7 @@ const userId = result.lastInsertRowid; ``` **Returns**: RunResult object with: + - `changes`: Number of rows affected - `lastInsertRowid`: ID of inserted row (for INSERT) @@ -293,10 +297,9 @@ const permissions = UserDatabaseService.all( ); // Get role with permissions -const role = UserDatabaseService.get( - 'SELECT * FROM roles WHERE id = ?', - [roleId] -); +const role = UserDatabaseService.get('SELECT * FROM roles WHERE id = ?', [ + roleId, +]); const rolePermissions = UserDatabaseService.all( `SELECT p.* FROM permissions p @@ -348,7 +351,15 @@ UserDatabaseService.run( `INSERT INTO activity_logs (user_id, username, action, resource, resource_id, details, ip_address) VALUES (?, ?, ?, ?, ?, ?, ?)`, - [userId, username, 'create', 'users', newUserId, JSON.stringify(details), ipAddress] + [ + userId, + username, + 'create', + 'users', + newUserId, + JSON.stringify(details), + ipAddress, + ] ); // Get recent activity @@ -369,9 +380,11 @@ UserDatabaseService.run( ## Database Schema -See [database/schema.md](../database/schema.md) for complete schema documentation. +See [database/schema.md](../database/schema.md) for complete schema +documentation. Main tables: + - `users` - User accounts - `roles` - User roles (admin, user, guest) - `permissions` - Available permissions @@ -415,16 +428,16 @@ const db = UserDatabaseService.getDatabase(); const createUser = db.transaction((username, email, passwordHash, roleId) => { // Create user - const userResult = db.prepare( - 'INSERT INTO users (username, email, password_hash, role_id) VALUES (?, ?, ?, ?)' - ).run(username, email, passwordHash, roleId); + const userResult = db + .prepare( + 'INSERT INTO users (username, email, password_hash, role_id) VALUES (?, ?, ?, ?)' + ) + .run(username, email, passwordHash, roleId); const userId = userResult.lastInsertRowid; // Initialize user stats - db.prepare( - 'INSERT INTO user_stats (user_id) VALUES (?)' - ).run(userId); + db.prepare('INSERT INTO user_stats (user_id) VALUES (?)').run(userId); return userId; }); @@ -434,6 +447,7 @@ const userId = createUser('newuser', 'user@example.com', hash, 2); ``` **Benefits**: + - Atomic: All operations succeed or all fail - Consistent: Database remains in valid state - Fast: Single disk write for all operations @@ -466,6 +480,7 @@ Migrations run automatically on startup. ### Migration File Naming Migrations are numbered sequentially: + ``` 001_user-schema.sql # Initial schema 002_create-user-settings.sql # Add user_settings table @@ -479,14 +494,19 @@ Migrations are numbered sequentially: ```typescript // Check if migration is needed -const tables = this.db!.prepare(` +const tables = this.db!.prepare( + ` SELECT name FROM sqlite_master WHERE type='table' AND name='new_table' -`).all(); +` +).all(); if (tables.length === 0) { logger.info('[UserDB] Running migration: 005_add-new-feature'); - const migrationPath = path.join(__dirname, '../migrations/005_add-new-feature.sql'); + const migrationPath = path.join( + __dirname, + '../migrations/005_add-new-feature.sql' + ); const migrationSQL = fs.readFileSync(migrationPath, 'utf-8'); this.db!.exec(migrationSQL); logger.info('[UserDB] Migration completed: 005_add-new-feature'); @@ -494,6 +514,7 @@ if (tables.length === 0) { ``` 3. Test on clean database: + ```bash rm user.db npm run dev @@ -513,6 +534,7 @@ npm run dev ### Connection Pooling Not needed - SQLite is single-threaded: + - Single connection shared across requests - Synchronous operations are fast - No connection overhead @@ -542,6 +564,7 @@ CREATE INDEX IF NOT EXISTS idx_activity_logs_user_id ON activity_logs(user_id); **Cause**: Another process has write lock **Solution**: + - Ensure only one backend instance - Check for abandoned connections - Use shorter transactions @@ -551,6 +574,7 @@ CREATE INDEX IF NOT EXISTS idx_activity_logs_user_id ON activity_logs(user_id); **Cause**: Attempting to insert/update with invalid foreign key **Solution**: + - Verify referenced record exists - Check ON DELETE CASCADE/RESTRICT constraints - Ensure foreign keys are enabled @@ -560,6 +584,7 @@ CREATE INDEX IF NOT EXISTS idx_activity_logs_user_id ON activity_logs(user_id); **Cause**: Migration SQL has error **Solution**: + - Check migration SQL syntax - Test migration file in SQLite CLI - Review error message in logs @@ -569,10 +594,9 @@ CREATE INDEX IF NOT EXISTS idx_activity_logs_user_id ON activity_logs(user_id); ### AuthService ```typescript -const user = UserDatabaseService.get( - 'SELECT * FROM users WHERE username = ?', - [username] -); +const user = UserDatabaseService.get('SELECT * FROM users WHERE username = ?', [ + username, +]); const passwordHash = await bcrypt.hash(password, config.bcryptSaltRounds); diff --git a/docs/03-backend/utilities.md b/docs/03-backend/utilities.md index 7d282f6..5216cf7 100644 --- a/docs/03-backend/utilities.md +++ b/docs/03-backend/utilities.md @@ -1,10 +1,12 @@ # Backend Utilities -This document describes the utility functions and middleware used throughout the backend service. +This document describes the utility functions and middleware used throughout the +backend service. ## Overview -The backend includes several reusable utilities that eliminate code duplication and enforce consistent patterns across the application: +The backend includes several reusable utilities that eliminate code duplication +and enforce consistent patterns across the application: ``` backend/src/ @@ -23,6 +25,7 @@ backend/src/ **Location**: `backend/src/middleware/validation.ts` **Benefits**: + - Eliminates 15+ instances of duplicate Zod error handling - Type-safe request validation - Consistent error messages @@ -34,7 +37,7 @@ backend/src/ function validateRequest( schema: T, property: RequestProperty = 'body' -): RequestHandler +): RequestHandler; type RequestProperty = 'body' | 'query' | 'params'; ``` @@ -48,11 +51,12 @@ import { z } from 'zod'; // Define schema const loginSchema = z.object({ username: z.string().min(3).max(50), - password: z.string().min(6) + password: z.string().min(6), }); // Apply middleware -router.post('/login', +router.post( + '/login', validateRequest(loginSchema, 'body'), async (req, res) => { // req.body is now validated and typed @@ -65,11 +69,13 @@ router.post('/login', **Error Handling**: When validation fails: + - Returns 400 Bad Request status - Provides descriptive error message - Forwards to error handler middleware Example error response: + ```json { "error": { @@ -93,6 +99,7 @@ Example error response: **Location**: `backend/src/utils/queryParser.ts` **Benefits**: + - Eliminates 15+ instances of duplicate pagination logic - Type-safe parsing with default values - Consistent limits and offset handling @@ -102,9 +109,9 @@ Example error response: ```typescript interface PaginationDefaults { - limit?: number; // Default: 50 - maxLimit?: number; // Default: 100 - offset?: number; // Default: 0 + limit?: number; // Default: 50 + maxLimit?: number; // Default: 100 + offset?: number; // Default: 0 } interface PaginationResult { @@ -115,7 +122,7 @@ interface PaginationResult { function parsePaginationQuery( query: any, defaults?: PaginationDefaults -): PaginationResult +): PaginationResult; ``` **Usage**: @@ -132,14 +139,14 @@ router.get('/games', async (req, res) => { const { limit, offset } = parsePaginationQuery(req.query, { limit: 20, maxLimit: 50, - offset: 0 + offset: 0, }); // Query database - const games = DatabaseService.all( - 'SELECT * FROM game LIMIT ? OFFSET ?', - [limit, offset] - ); + const games = DatabaseService.all('SELECT * FROM game LIMIT ? OFFSET ?', [ + limit, + offset, + ]); res.json({ games, limit, offset }); }); @@ -147,13 +154,13 @@ router.get('/games', async (req, res) => { **Behavior**: -| Input | Output | Notes | -|-------|--------|-------| -| `?limit=10` | `{ limit: 10, offset: 0 }` | Valid limit | -| `?limit=200` | `{ limit: 100, offset: 0 }` | Clamped to maxLimit | -| `?offset=20` | `{ limit: 50, offset: 20 }` | Custom offset | -| `?limit=abc` | `{ limit: 50, offset: 0 }` | Invalid, uses default | -| `?offset=-5` | `{ limit: 50, offset: 0 }` | Negative prevented | +| Input | Output | Notes | +| ------------ | --------------------------- | --------------------- | +| `?limit=10` | `{ limit: 10, offset: 0 }` | Valid limit | +| `?limit=200` | `{ limit: 100, offset: 0 }` | Clamped to maxLimit | +| `?offset=20` | `{ limit: 50, offset: 20 }` | Custom offset | +| `?limit=abc` | `{ limit: 50, offset: 0 }` | Invalid, uses default | +| `?offset=-5` | `{ limit: 50, offset: 0 }` | Negative prevented | **Type Safety**: @@ -180,11 +187,17 @@ function sanitizeBody(body: any): any { const sanitized = { ...body }; const sensitiveFields = [ - 'password', 'currentPassword', 'newPassword', - 'token', 'refreshToken', 'secret', 'apiKey', 'accessToken' + 'password', + 'currentPassword', + 'newPassword', + 'token', + 'refreshToken', + 'secret', + 'apiKey', + 'accessToken', ]; - sensitiveFields.forEach(field => { + sensitiveFields.forEach((field) => { if (sanitized[field]) { sanitized[field] = '[REDACTED]'; } @@ -201,16 +214,18 @@ function sanitizeBody(body: any): any { logger.error('[UnhandledError]', { message: err.message, stack: err.stack, - body: sanitizeBody(req.body) // Passwords/tokens redacted + body: sanitizeBody(req.body), // Passwords/tokens redacted }); ``` **Protected Fields**: + - `password`, `currentPassword`, `newPassword` - `token`, `refreshToken`, `accessToken` - `secret`, `apiKey` Example log output: + ```json { "message": "Login failed", @@ -228,14 +243,15 @@ Example log output: Use `validateRequest` for ALL endpoints that accept user input: ✅ **DO**: + ```typescript -router.post('/users', - validateRequest(createUserSchema), - async (req, res) => { /* ... */ } -); +router.post('/users', validateRequest(createUserSchema), async (req, res) => { + /* ... */ +}); ``` ❌ **DON'T**: + ```typescript router.post('/users', async (req, res) => { // Manual validation (code duplication) @@ -250,15 +266,17 @@ router.post('/users', async (req, res) => { Use `parsePaginationQuery` for ALL endpoints that return paginated data: ✅ **DO**: + ```typescript const { limit, offset } = parsePaginationQuery(req.query); -const games = DatabaseService.all( - 'SELECT * FROM game LIMIT ? OFFSET ?', - [limit, offset] -); +const games = DatabaseService.all('SELECT * FROM game LIMIT ? OFFSET ?', [ + limit, + offset, +]); ``` ❌ **DON'T**: + ```typescript // Manual parsing (code duplication) const limit = Math.min(parseInt(req.query.limit || '50'), 100); @@ -270,7 +288,8 @@ const offset = Math.max(parseInt(req.query.offset || '0'), 0); Combine utilities for robust error handling: ```typescript -router.post('/games/:id/play', +router.post( + '/games/:id/play', authenticate, validateRequest(playSessionSchema), async (req, res, next) => { @@ -321,5 +340,4 @@ router.post('/games/:id/play', --- -**Last Updated**: 2026-01-27 -**Review Status**: Current +**Last Updated**: 2026-01-27 **Review Status**: Current diff --git a/docs/04-frontend/README.md b/docs/04-frontend/README.md index 8100087..fa4aa94 100644 --- a/docs/04-frontend/README.md +++ b/docs/04-frontend/README.md @@ -1,6 +1,7 @@ # Frontend Documentation -Modern React 18 single-page application (SPA) built with TypeScript, Vite, and optimized libraries. +Modern React 18 single-page application (SPA) built with TypeScript, Vite, and +optimized libraries. ## Tech Stack @@ -21,29 +22,41 @@ Modern React 18 single-page application (SPA) built with TypeScript, Vite, and o ### Core Documentation - [architecture.md](./architecture.md) - Frontend architecture patterns -- [views-routing.md](./views-routing.md) - All views, routes, and route protection +- [views-routing.md](./views-routing.md) - All views, routes, and route + protection - [api-client.md](./api-client.md) - Axios client and API endpoints - [custom-hooks.md](./custom-hooks.md) - 17+ custom hooks with examples ### Components -- [components/component-overview.md](./components/component-overview.md) - Component organization -- [components/layout-components.md](./components/layout-components.md) - AppShell, Header, Sidebar -- [components/game-components.md](./components/game-components.md) - GameCard, GameGrid, list components -- [components/player-components.md](./components/player-components.md) - GamePlayer, RufflePlayer -- [components/auth-components.md](./components/auth-components.md) - LoginForm, RegisterForm, ProtectedRoute -- [components/ui-components.md](./components/ui-components.md) - Shadcn UI reference +- [components/component-overview.md](./components/component-overview.md) - + Component organization +- [components/layout-components.md](./components/layout-components.md) - + AppShell, Header, Sidebar +- [components/game-components.md](./components/game-components.md) - GameCard, + GameGrid, list components +- [components/player-components.md](./components/player-components.md) - + GamePlayer, RufflePlayer +- [components/auth-components.md](./components/auth-components.md) - LoginForm, + RegisterForm, ProtectedRoute +- [components/ui-components.md](./components/ui-components.md) - Shadcn UI + reference ### State Management -- [state-management/zustand-stores.md](./state-management/zustand-stores.md) - useAuthStore, useThemeStore, useUIStore -- [state-management/react-query.md](./state-management/react-query.md) - TanStack Query usage and caching -- [state-management/url-state.md](./state-management/url-state.md) - URL-based filter state +- [state-management/zustand-stores.md](./state-management/zustand-stores.md) - + useAuthStore, useThemeStore, useUIStore +- [state-management/react-query.md](./state-management/react-query.md) - + TanStack Query usage and caching +- [state-management/url-state.md](./state-management/url-state.md) - URL-based + filter state ### Player Implementation -- [player-implementation/ruffle-player.md](./player-implementation/ruffle-player.md) - Flash game player -- [player-implementation/html5-player.md](./player-implementation/html5-player.md) - HTML5 game iframe player +- [player-implementation/ruffle-player.md](./player-implementation/ruffle-player.md) - + Flash game player +- [player-implementation/html5-player.md](./player-implementation/html5-player.md) - + HTML5 game iframe player ## Quick Start @@ -81,6 +94,7 @@ npm run typecheck ### Server State Management All server data (games, playlists, user data) managed through TanStack Query: + - Automatic background refetching - Optimistic updates - Request deduplication @@ -88,7 +102,9 @@ All server data (games, playlists, user data) managed through TanStack Query: ### UI State Management -Client-side UI state (sidebar, theme, auth) uses Zustand stores with localStorage persistence: +Client-side UI state (sidebar, theme, auth) uses Zustand stores with +localStorage persistence: + - Minimal boilerplate - TypeScript-first - DevTools support @@ -97,6 +113,7 @@ Client-side UI state (sidebar, theme, auth) uses Zustand stores with localStorag ### Type Safety Fully typed with TypeScript: + - Strict mode enabled - No implicit any - Comprehensive type definitions for API responses @@ -119,9 +136,11 @@ Fully typed with TypeScript: ## Environment Variables -The frontend does not require environment variables for local development. API calls are automatically proxied through Vite to the backend. +The frontend does not require environment variables for local development. API +calls are automatically proxied through Vite to the backend. **Optional (production builds):** + ```bash VITE_APP_VERSION=1.0.0 # Displayed app version ``` @@ -152,6 +171,7 @@ frontend/ ## Vite Configuration Dev server proxies `/api/*` to backend: + ```typescript server: { proxy: { @@ -176,7 +196,8 @@ npm run lint # ESLint code linting ## Related Documentation -- [Architecture Documentation](../02-architecture/README.md) - System architecture +- [Architecture Documentation](../02-architecture/README.md) - System + architecture - [Backend Documentation](../03-backend/README.md) - Backend API reference - [API Reference](../06-api-reference/README.md) - Complete API documentation - [Development Workflow](../08-development/setup-guide.md) - Setup guide diff --git a/docs/04-frontend/api-client.md b/docs/04-frontend/api-client.md index abd5a09..a85df1f 100644 --- a/docs/04-frontend/api-client.md +++ b/docs/04-frontend/api-client.md @@ -1,10 +1,12 @@ # API Client -Complete documentation of the Axios-based API client and all available endpoints. +Complete documentation of the Axios-based API client and all available +endpoints. ## Overview -The API client is a centralized Axios instance with request/response interceptors for authentication and error handling. +The API client is a centralized Axios instance with request/response +interceptors for authentication and error handling. **Location:** `frontend/src/lib/api.ts` @@ -14,8 +16,8 @@ The API client is a centralized Axios instance with request/response interceptor const api = axios.create({ baseURL: '/api', headers: { - 'Content-Type': 'application/json' - } + 'Content-Type': 'application/json', + }, }); ``` @@ -26,15 +28,13 @@ const api = axios.create({ Automatically adds JWT token to all requests: ```typescript -api.interceptors.request.use( - (config) => { - const token = useAuthStore.getState().accessToken; - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; +api.interceptors.request.use((config) => { + const token = useAuthStore.getState().accessToken; + if (token) { + config.headers.Authorization = `Bearer ${token}`; } -); + return config; +}); ``` ### Response Interceptor diff --git a/docs/04-frontend/architecture.md b/docs/04-frontend/architecture.md index a5ae236..60aaab3 100644 --- a/docs/04-frontend/architecture.md +++ b/docs/04-frontend/architecture.md @@ -1,10 +1,12 @@ # Frontend Architecture -This document describes the architectural patterns, design decisions, and technical implementation details of the Flashpoint Web frontend. +This document describes the architectural patterns, design decisions, and +technical implementation details of the Flashpoint Web frontend. ## Architecture Overview -The frontend follows a modern React architecture with clear separation of concerns: +The frontend follows a modern React architecture with clear separation of +concerns: ``` ┌─────────────────────────────────────────────────────────────┐ @@ -77,6 +79,7 @@ The application uses a **three-tier state management approach**: All server-side data (games, playlists, users) is managed by TanStack Query: **Benefits:** + - Automatic caching with configurable stale times - Background refetching - Request deduplication @@ -89,12 +92,12 @@ All server-side data (games, playlists, users) is managed by TanStack Query: const queryClient = new QueryClient({ defaultOptions: { queries: { - staleTime: 5 * 60 * 1000, // 5 minutes + staleTime: 5 * 60 * 1000, // 5 minutes cacheTime: 10 * 60 * 1000, // 10 minutes refetchOnWindowFocus: false, - retry: 1 - } - } + retry: 1, + }, + }, }); ``` @@ -106,7 +109,7 @@ export function useGames(filters: GameFilters) { return useQuery({ queryKey: ['games', filters], queryFn: () => gamesApi.search(filters), - staleTime: 5 * 60 * 1000 + staleTime: 5 * 60 * 1000, }); } ``` @@ -116,6 +119,7 @@ export function useGames(filters: GameFilters) { Client-side UI state (authentication, theme, sidebar) uses Zustand: **Benefits:** + - Minimal boilerplate - No providers needed - Built-in TypeScript support @@ -142,11 +146,15 @@ persist( // Guest sessions use sessionStorage // Authenticated sessions use localStorage }, - setItem: (name, value) => { /* ... */ }, - removeItem: (name) => { /* ... */ } - })) + setItem: (name, value) => { + /* ... */ + }, + removeItem: (name) => { + /* ... */ + }, + })), } -) +); ``` #### Tier 3: URL State (React Router) @@ -154,6 +162,7 @@ persist( Filter state and pagination are stored in URL search params: **Benefits:** + - Shareable URLs - Browser back/forward support - No client-side state management needed @@ -221,9 +230,9 @@ Routes are defined in `App.tsx` with nested routing structure: ```typescript interface ProtectedRouteProps { children: React.ReactNode; - requireAuth?: boolean; // Default: true - requirePermission?: string; // Optional permission check - requireAnyPermission?: string[]; // Any of these permissions + requireAuth?: boolean; // Default: true + requirePermission?: string; // Optional permission check + requireAnyPermission?: string[]; // Any of these permissions requireAllPermissions?: string[]; // All of these permissions } ``` @@ -244,8 +253,8 @@ Centralized API client in `lib/api.ts`: const api = axios.create({ baseURL: '/api', headers: { - 'Content-Type': 'application/json' - } + 'Content-Type': 'application/json', + }, }); ``` @@ -254,15 +263,13 @@ const api = axios.create({ Automatically adds JWT token to all requests: ```typescript -api.interceptors.request.use( - (config) => { - const token = useAuthStore.getState().accessToken; - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; +api.interceptors.request.use((config) => { + const token = useAuthStore.getState().accessToken; + if (token) { + config.headers.Authorization = `Bearer ${token}`; } -); + return config; +}); ``` #### Response Interceptor @@ -331,7 +338,8 @@ All types are defined in `src/types/`: #### Code Splitting -**Route-level lazy loading** - All views use React.lazy for optimal bundle splitting: +**Route-level lazy loading** - All views use React.lazy for optimal bundle +splitting: ```typescript // App.tsx - Lazy load all views @@ -377,25 +385,31 @@ export const GameCard = memo(GameCardComponent, (prevProps, nextProps) => { if (prevProps.game.id !== nextProps.game.id) return false; // Or if favorite status changed - const prevIsFavorited = prevProps.favoriteGameIds?.has(prevProps.game.id) ?? false; - const nextIsFavorited = nextProps.favoriteGameIds?.has(nextProps.game.id) ?? false; + const prevIsFavorited = + prevProps.favoriteGameIds?.has(prevProps.game.id) ?? false; + const nextIsFavorited = + nextProps.favoriteGameIds?.has(nextProps.game.id) ?? false; if (prevIsFavorited !== nextIsFavorited) return false; // Or if button visibility changed - if (prevProps.showFavoriteButton !== nextProps.showFavoriteButton || - prevProps.showRemoveButton !== nextProps.showRemoveButton) return false; + if ( + prevProps.showFavoriteButton !== nextProps.showFavoriteButton || + prevProps.showRemoveButton !== nextProps.showRemoveButton + ) + return false; return true; // Don't re-render }); ``` -**Impact**: In a 50-card grid, only 1 card re-renders when favoriting instead of all 50 (49 prevented renders = 98% reduction) +**Impact**: In a 50-card grid, only 1 card re-renders when favoriting instead of +all 50 (49 prevented renders = 98% reduction) **useMemo()** for computed values: ```typescript const filteredGames = useMemo( - () => games.filter(game => game.platform === selectedPlatform), + () => games.filter((game) => game.platform === selectedPlatform), [games, selectedPlatform] ); ``` @@ -403,9 +417,12 @@ const filteredGames = useMemo( **useCallback()** for event handlers: ```typescript -const handleFavorite = useCallback((gameId: string) => { - favoriteMutation.mutate(gameId); -}, [favoriteMutation]); +const handleFavorite = useCallback( + (gameId: string) => { + favoriteMutation.mutate(gameId); + }, + [favoriteMutation] +); ``` #### Image Optimization @@ -445,6 +462,7 @@ try { ``` **Benefits**: + - Type-safe error handling - Consistent error message extraction - Handles Axios errors, Error objects, and unknown types @@ -492,7 +510,7 @@ All API calls are wrapped in custom hooks: export function useGames(filters: GameFilters) { return useQuery({ queryKey: ['games', filters], - queryFn: () => gamesApi.search(filters) + queryFn: () => gamesApi.search(filters), }); } @@ -539,8 +557,8 @@ export default defineConfig({ server: { proxy: { '/api': 'http://localhost:3100', - '/proxy': 'http://localhost:3100' - } + '/proxy': 'http://localhost:3100', + }, }, build: { outDir: 'dist', @@ -548,12 +566,12 @@ export default defineConfig({ rollupOptions: { output: { manualChunks: { - 'vendor': ['react', 'react-dom', 'react-router-dom'], - 'ui': ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu'] - } - } - } - } + vendor: ['react', 'react-dom', 'react-router-dom'], + ui: ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu'], + }, + }, + }, + }, }); ``` @@ -564,6 +582,7 @@ npm run build ``` Outputs: + - Minified JavaScript bundles - Code-split chunks - Optimized CSS diff --git a/docs/04-frontend/components/auth-components.md b/docs/04-frontend/components/auth-components.md index 659f1d3..38b1728 100644 --- a/docs/04-frontend/components/auth-components.md +++ b/docs/04-frontend/components/auth-components.md @@ -13,10 +13,10 @@ Route wrapper component that handles authorization and route protection. ```typescript interface ProtectedRouteProps { children: React.ReactNode; - requireAuth?: boolean; // Default: true - requirePermission?: string; // Single permission - requireAnyPermission?: string[]; // Any of these permissions - requireAllPermissions?: string[]; // All of these permissions + requireAuth?: boolean; // Default: true + requirePermission?: string; // Single permission + requireAnyPermission?: string[]; // Any of these permissions + requireAllPermissions?: string[]; // All of these permissions } ``` @@ -118,9 +118,9 @@ Component wrapper that conditionally renders based on permissions. ```typescript interface RoleGuardProps { children: React.ReactNode; - permission?: string; // Single permission - permissions?: string[]; // Any of these - requireAll?: boolean; // Require all permissions + permission?: string; // Single permission + permissions?: string[]; // Any of these + requireAll?: boolean; // Require all permissions } ``` diff --git a/docs/04-frontend/components/component-overview.md b/docs/04-frontend/components/component-overview.md index da66450..a7dbf33 100644 --- a/docs/04-frontend/components/component-overview.md +++ b/docs/04-frontend/components/component-overview.md @@ -1,6 +1,7 @@ # Component Overview -This document provides an overview of the component architecture and organization in the Flashpoint Web frontend. +This document provides an overview of the component architecture and +organization in the Flashpoint Web frontend. ## Component Organization @@ -41,7 +42,8 @@ Located in `components/auth/` Located in `components/common/` -- **FormattedDate** - Reusable date/time formatting component with user preferences +- **FormattedDate** - Reusable date/time formatting component with user + preferences - **CardSizeControl** - Control for adjusting card size in grid views - **ConfirmDialog** - Generic confirmation dialog for destructive actions - **ErrorBoundary** - Error boundary wrapper for graceful error handling @@ -57,14 +59,20 @@ Located in `components/common/` Located in `components/playlist/` -- **SharePlaylistDialog** - Dialog for sharing playlists via URL. Admin users see a domain selector dropdown to choose which domain to use for the share link. Non-admin users use the default domain from public settings. Share URLs are constructed client-side via the `buildShareUrl()` utility. +- **SharePlaylistDialog** - Dialog for sharing playlists via URL. Admin users + see a domain selector dropdown to choose which domain to use for the share + link. Non-admin users use the default domain from public settings. Share URLs + are constructed client-side via the `buildShareUrl()` utility. - Other playlist management components... ### Settings Components Located in `components/settings/` -- **AppSettingsTab** - Application settings including Site Name, Maintenance Mode, Theme, and **Domain Settings**. The Domain Settings card allows admins to add/delete domains and set a default via radio buttons, with hostname validation. +- **AppSettingsTab** - Application settings including Site Name, Maintenance + Mode, Theme, and **Domain Settings**. The Domain Settings card allows admins + to add/delete domains and set a default via radio buttons, with hostname + validation. - Other settings tabs... ### Layout Components @@ -97,9 +105,12 @@ Located in `components/player/` Located in `components/ui/` -The application uses [Shadcn UI](https://ui.shadcn.com/) - a collection of re-usable components built with Radix UI and Tailwind CSS. These are copied into the project and can be customized. +The application uses [Shadcn UI](https://ui.shadcn.com/) - a collection of +re-usable components built with Radix UI and Tailwind CSS. These are copied into +the project and can be customized. **40+ components including:** + - Button, Input, Textarea, Select, Checkbox, Switch - Dialog, Alert Dialog, Sheet, Popover, Dropdown Menu - Card, Badge, Avatar, Separator, Skeleton @@ -195,10 +206,14 @@ export function GameCardSkeleton() { ```typescript // Named export (preferred for main components) -export function GameCard({ game }: GameCardProps) { /* ... */ } +export function GameCard({ game }: GameCardProps) { + /* ... */ +} // Default export (used for views/pages) -export default function BrowseView() { /* ... */ } +export default function BrowseView() { + /* ... */ +} ``` ## Component Props Best Practices @@ -213,9 +228,9 @@ interface GamePlayerProps { canPlayInBrowser: boolean; // Optional props with defaults - allowFullscreen?: boolean; // Default: true - showControls?: boolean; // Default: true - height?: string; // Default: 'calc(100vh - 220px)' + allowFullscreen?: boolean; // Default: true + showControls?: boolean; // Default: true + height?: string; // Default: 'calc(100vh - 220px)' // Optional callbacks onFullscreenChange?: (isFullscreen: boolean) => void; @@ -394,9 +409,14 @@ const sortedGames = useMemo(() => { Memoize callback functions: ```typescript -const handleAddToPlaylist = useCallback((gameId: string) => { - // Add to playlist logic -}, [/* dependencies */]); +const handleAddToPlaylist = useCallback( + (gameId: string) => { + // Add to playlist logic + }, + [ + /* dependencies */ + ] +); ``` ### Lazy Loading diff --git a/docs/04-frontend/components/game-components.md b/docs/04-frontend/components/game-components.md index 2471ce9..9ee9c18 100644 --- a/docs/04-frontend/components/game-components.md +++ b/docs/04-frontend/components/game-components.md @@ -23,7 +23,8 @@ interface GameCardProps { ### Features - **Image Display** - Shows game logo or screenshot with lazy loading -- **Availability Indicator** - Green checkmark (available) or yellow download icon (needs download) +- **Availability Indicator** - Green checkmark (available) or yellow download + icon (needs download) - **Platform Badge** - Platform logo and name - **Hover Actions** - Add to playlist, toggle favorite (shown on hover) - **Loading States** - Blur placeholder while image loads @@ -51,7 +52,7 @@ Responsive grid layout for game cards. ```typescript interface GameGridProps { games: Game[]; - cardSize?: CardSize; // 'small' | 'medium' | 'large' + cardSize?: CardSize; // 'small' | 'medium' | 'large' onAddToPlaylist?: (gameId: string) => void; onToggleFavorite?: (gameId: string) => void; favoritedGames?: string[]; @@ -60,9 +61,12 @@ interface GameGridProps { ### Grid Breakpoints -- Small cards: `grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8` -- Medium cards: `grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6` -- Large cards: `grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5` +- Small cards: + `grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8` +- Medium cards: + `grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6` +- Large cards: + `grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5` ## GameList diff --git a/docs/04-frontend/components/layout-components.md b/docs/04-frontend/components/layout-components.md index 9a52328..53bb9c9 100644 --- a/docs/04-frontend/components/layout-components.md +++ b/docs/04-frontend/components/layout-components.md @@ -1,6 +1,7 @@ # Layout Components -This document describes the layout components that form the structure of the Flashpoint Web application. +This document describes the layout components that form the structure of the +Flashpoint Web application. ## Component Hierarchy @@ -26,9 +27,11 @@ AppShell Main application layout container that wraps all pages. ### Location + `D:\Repositories\Personal\flashpoint-web\frontend\src\components\layout\AppShell.tsx` ### Purpose + Provides the main application structure with header and sidebar navigation. ### Props @@ -86,6 +89,7 @@ export function AppShell({ children }: AppShellProps) { Top navigation bar with search, theme controls, and user menu. ### Location + `D:\Repositories\Personal\flashpoint-web\frontend\src\components\layout\Header.tsx` ### Features @@ -236,6 +240,7 @@ No props - reads state from Zustand stores. Collapsible navigation sidebar with route highlighting. ### Location + `D:\Repositories\Personal\flashpoint-web\frontend\src\components\layout\Sidebar.tsx` ### Props @@ -277,8 +282,16 @@ interface SidebarProps { ```typescript const gameNavItems: NavItem[] = [ - { path: '/flash-games', iconImage: '/images/Flash.png', label: 'Flash Games' }, - { path: '/html5-games', iconImage: '/images/HTML5.png', label: 'HTML5 Games' }, + { + path: '/flash-games', + iconImage: '/images/Flash.png', + label: 'Flash Games', + }, + { + path: '/html5-games', + iconImage: '/images/HTML5.png', + label: 'HTML5 Games', + }, { path: '/animations', icon: Film, label: 'Animations' }, { path: '/browse', icon: Gamepad2, label: 'Browse' }, ]; @@ -338,7 +351,9 @@ const isNavItemActive = (item: NavItem) => { if (itemQuery) { // Exact match including query params - return location.pathname === itemPath && location.search === `?${itemQuery}`; + return ( + location.pathname === itemPath && location.search === `?${itemQuery}` + ); } // Special case: Home (/) should highlight Flash Games @@ -361,7 +376,7 @@ const sidebarRef = useSwipeGesture({ setSidebarOpen(false); } }, - minSwipeDistance: 50 + minSwipeDistance: 50, }); ``` @@ -406,6 +421,7 @@ useEffect(() => { ### Collapsed State Behavior When `sidebarCollapsed` is true: + - Width: 64px (w-16) - Icons only (no text) - Tooltips on hover (via `title` attribute) @@ -433,15 +449,17 @@ UI state is persisted to localStorage: ```typescript persist( - (set) => ({ /* ... */ }), + (set) => ({ + /* ... */ + }), { name: 'flashpoint-ui-state', partialize: (state) => ({ sidebarCollapsed: state.sidebarCollapsed, // sidebarOpen is intentionally not persisted (session-only) - }) + }), } -) +); ``` ## Styling and Theming @@ -449,6 +467,7 @@ persist( ### Tailwind Classes Layout components use Tailwind utility classes: + - `bg-card` - Card background color (theme-aware) - `border-border` - Border color (theme-aware) - `text-foreground` - Text color (theme-aware) @@ -457,6 +476,7 @@ Layout components use Tailwind utility classes: ### Custom CSS Variables Applied via ThemeStore: + - `--primary` - Primary theme color - `--background` - Background color - `--foreground` - Text color diff --git a/docs/04-frontend/components/player-components.md b/docs/04-frontend/components/player-components.md index 5d51c45..ec98096 100644 --- a/docs/04-frontend/components/player-components.md +++ b/docs/04-frontend/components/player-components.md @@ -1,6 +1,7 @@ # Player Components -This document describes the game player components used to render Flash and HTML5 games. +This document describes the game player components used to render Flash and +HTML5 games. ## Component Overview @@ -14,6 +15,7 @@ The player architecture consists of two main components: Universal game player component that handles Flash (Ruffle) and HTML5 games. ### Location + `D:\Repositories\Personal\flashpoint-web\frontend\src\components\player\GamePlayer.tsx` ### Props @@ -257,6 +259,7 @@ allow=" Flash emulator component using Ruffle WebAssembly. ### Location + `D:\Repositories\Personal\flashpoint-web\frontend\src\components\player\RufflePlayer.tsx` ### Props @@ -313,7 +316,7 @@ useEffect(() => { try { // Wait for any previous cleanup - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise((resolve) => setTimeout(resolve, 100)); if (!mounted) return; @@ -328,12 +331,14 @@ useEffect(() => { oldPlayer.remove(); } - await new Promise(resolve => setTimeout(resolve, 200)); + await new Promise((resolve) => setTimeout(resolve, 200)); } // Load Ruffle script if not already loaded if (!(window as any).RufflePlayer) { - const existingScript = document.querySelector('script[src="/ruffle/ruffle.js"]'); + const existingScript = document.querySelector( + 'script[src="/ruffle/ruffle.js"]' + ); if (!existingScript) { const script = document.createElement('script'); @@ -341,14 +346,15 @@ useEffect(() => { await new Promise((resolve, reject) => { script.onload = () => resolve(); - script.onerror = () => reject(new Error('Failed to load Ruffle script')); + script.onerror = () => + reject(new Error('Failed to load Ruffle script')); document.head.appendChild(script); }); } } // Wait for Ruffle to initialize - await new Promise(resolve => setTimeout(resolve, 200)); + await new Promise((resolve) => setTimeout(resolve, 200)); // Create player const RufflePlayer = (window as any).RufflePlayer; @@ -427,22 +433,22 @@ useEffect(() => { ```typescript player.config = { - autoplay: 'auto', // Auto-start playback - backgroundColor: '#000000', // Black background - letterbox: 'on', // Letterbox for aspect ratio - unmuteOverlay: 'visible', // Show unmute button if needed - contextMenu: true, // Enable right-click menu - showSwfDownload: false, // Hide download option + autoplay: 'auto', // Auto-start playback + backgroundColor: '#000000', // Black background + letterbox: 'on', // Letterbox for aspect ratio + unmuteOverlay: 'visible', // Show unmute button if needed + contextMenu: true, // Enable right-click menu + showSwfDownload: false, // Hide download option upgradeToHttps: window.location.protocol === 'https:', warnOnUnsupportedContent: true, - logLevel: 'error', // Only log errors - publicPath: '/ruffle/', // Path to Ruffle files - scale: 'showAll', // Show all content, maintain aspect - forceScale: true, // Prevent runtime scale changes - quality: 'high', // High quality rendering + logLevel: 'error', // Only log errors + publicPath: '/ruffle/', // Path to Ruffle files + scale: 'showAll', // Show all content, maintain aspect + forceScale: true, // Prevent runtime scale changes + quality: 'high', // High quality rendering allowScriptAccess: 'sameDomain', - salign: '', // Center alignment - wmode: 'opaque', // Opaque mode + salign: '', // Center alignment + wmode: 'opaque', // Opaque mode }; ``` @@ -450,14 +456,16 @@ player.config = { Ruffle supports multiple scale modes (configured via `scale` option): -- **showAll** (Default) - Show entire content, maintain aspect ratio, add letterboxing if needed +- **showAll** (Default) - Show entire content, maintain aspect ratio, add + letterboxing if needed - **noscale** - No scaling, 1:1 pixel mapping - **exactfit** - Scale to fill container, ignore aspect ratio - **noborder** - Scale to fill, maintain aspect, crop if needed ### Cleanup Strategy -The component implements a robust cleanup strategy to prevent memory leaks and stale instances: +The component implements a robust cleanup strategy to prevent memory leaks and +stale instances: 1. **Mount Flag** - Track if component is mounted 2. **Initialization Lock** - Prevent multiple simultaneous initializations diff --git a/docs/04-frontend/components/ui-components.md b/docs/04-frontend/components/ui-components.md index c0c13b7..04ce7de 100644 --- a/docs/04-frontend/components/ui-components.md +++ b/docs/04-frontend/components/ui-components.md @@ -4,13 +4,15 @@ Documentation for Shadcn UI components used in the application. ## Overview -The application uses [Shadcn UI](https://ui.shadcn.com/) - a collection of beautifully designed, accessible, and customizable components built with: +The application uses [Shadcn UI](https://ui.shadcn.com/) - a collection of +beautifully designed, accessible, and customizable components built with: - **Radix UI** - Unstyled, accessible component primitives - **Tailwind CSS** - Utility-first styling - **CVA** (Class Variance Authority) - Component variants -All components are located in `frontend/src/components/ui/` and can be customized directly. +All components are located in `frontend/src/components/ui/` and can be +customized directly. ## Core Components @@ -32,6 +34,7 @@ import { Button } from '@/components/ui/button'; ``` **Variants:** + - `default` - Primary action button - `destructive` - Dangerous actions (delete, remove) - `outline` - Secondary actions @@ -168,7 +171,7 @@ toast.warning('Warning message'); toast.promise(asyncFunction, { loading: 'Loading...', success: 'Success!', - error: 'Failed' + error: 'Failed', }); ``` @@ -291,7 +294,9 @@ import { Skeleton } from '@/components/ui/skeleton'; ## Customization -All components can be customized by editing files in `frontend/src/components/ui/`. They use Tailwind CSS classes and CSS variables for theming. +All components can be customized by editing files in +`frontend/src/components/ui/`. They use Tailwind CSS classes and CSS variables +for theming. ### Theme Variables diff --git a/docs/04-frontend/custom-hooks.md b/docs/04-frontend/custom-hooks.md index 6582ff4..c207c79 100644 --- a/docs/04-frontend/custom-hooks.md +++ b/docs/04-frontend/custom-hooks.md @@ -1,6 +1,7 @@ # Custom Hooks -Comprehensive documentation of all custom React hooks in the Flashpoint Web frontend. +Comprehensive documentation of all custom React hooks in the Flashpoint Web +frontend. ## Overview @@ -8,8 +9,10 @@ The application uses 18+ custom hooks organized by functionality: - **Data Fetching** - useGames, usePlaylists, useUsers, useRoles, useActivities - **Authentication** - useAuth -- **UI** - useDebounce, useSwipeGesture, useMountEffect, useToast, useDateTimeFormat -- **Game Data** - useFilterOptions, useTags, usePlatforms, useStatistics, useDownload +- **UI** - useDebounce, useSwipeGesture, useMountEffect, useToast, + useDateTimeFormat +- **Game Data** - useFilterOptions, useTags, usePlatforms, useStatistics, + useDownload - **Play Tracking** - usePlayTracking - **Community** - useCommunityPlaylists @@ -39,6 +42,7 @@ const { data: launchData } = useGameLaunchData(id: string); ``` **Example:** + ```typescript function BrowseView() { const [filters, setFilters] = useState({ @@ -95,7 +99,8 @@ const fallback = buildShareUrl(null, shareToken); // => "{window.location.origin}/playlists/shared/{token}" ``` -All mutations automatically invalidate the `['system-settings', 'public']` cache so the default domain updates across the app. +All mutations automatically invalidate the `['system-settings', 'public']` cache +so the default domain updates across the app. ## Authentication Hook @@ -112,7 +117,11 @@ const { login, logout, register } = useAuth(); await login({ username: 'user', password: 'pass' }); // Register -await register({ username: 'newuser', email: 'email@example.com', password: 'pass' }); +await register({ + username: 'newuser', + email: 'email@example.com', + password: 'pass', +}); // Logout await logout(); @@ -165,19 +174,21 @@ Date and time formatting hook with user-configurable formats. **Location:** `frontend/src/hooks/useDateTimeFormat.ts` -**Purpose:** Provides consistent date/time formatting across the application using user-selected format preferences from system settings. +**Purpose:** Provides consistent date/time formatting across the application +using user-selected format preferences from system settings. ```typescript const { - dateFormat, // Current date format string (e.g., 'MM/dd/yyyy') - timeFormat, // Current time format string (e.g., 'hh:mm a') - formatDate, // Function to format dates - formatTime, // Function to format times - formatDateTime // Function to format date + time + dateFormat, // Current date format string (e.g., 'MM/dd/yyyy') + timeFormat, // Current time format string (e.g., 'hh:mm a') + formatDate, // Function to format dates + formatTime, // Function to format times + formatDateTime, // Function to format date + time } = useDateTimeFormat(); ``` **Available Date Formats:** + - `MM/dd/yyyy` - US format (01/24/2026) - `dd/MM/yyyy` - European format (24/01/2026) - `yyyy-MM-dd` - ISO 8601 format (2026-01-24) @@ -187,6 +198,7 @@ const { - `dd MMMM yyyy` - Day-first full month (24 January 2026) **Available Time Formats:** + - `hh:mm a` - 12-hour with AM/PM (02:30 PM) - `HH:mm` - 24-hour (14:30) - `hh:mm:ss a` - 12-hour with seconds (02:30:45 PM) @@ -226,13 +238,15 @@ function GameStats() { ``` **Features:** + - Automatically fetches user's format preferences from system settings - Caches settings with 5-minute staleTime - Handles Date objects, ISO strings, and timestamps - Falls back to defaults if settings not loaded - Uses date-fns for reliable formatting -**See Also:** [Date & Time Formatting Feature](../../10-features/10-date-time-formatting.md) +**See Also:** +[Date & Time Formatting Feature](../../10-features/10-date-time-formatting.md) ### useFilterOptions @@ -268,12 +282,12 @@ All hooks use TanStack Query with these defaults: const queryClient = new QueryClient({ defaultOptions: { queries: { - staleTime: 5 * 60 * 1000, // 5 minutes - cacheTime: 10 * 60 * 1000, // 10 minutes + staleTime: 5 * 60 * 1000, // 5 minutes + cacheTime: 10 * 60 * 1000, // 10 minutes refetchOnWindowFocus: false, - retry: 1 - } - } + retry: 1, + }, + }, }); ``` diff --git a/docs/04-frontend/player-implementation/html5-player.md b/docs/04-frontend/player-implementation/html5-player.md index 89d950a..505c92b 100644 --- a/docs/04-frontend/player-implementation/html5-player.md +++ b/docs/04-frontend/player-implementation/html5-player.md @@ -4,7 +4,9 @@ Documentation for HTML5 game iframe player. ## Overview -HTML5 games are rendered using a sandboxed iframe for security and isolation. Unlike Flash games that require emulation, HTML5 games run natively in the browser. +HTML5 games are rendered using a sandboxed iframe for security and isolation. +Unlike Flash games that require emulation, HTML5 games run natively in the +browser. **Location:** `frontend/src/components/player/GamePlayer.tsx` (HTML5 branch) @@ -66,6 +68,7 @@ allow=" ### What's Blocked Without explicit permission, the iframe CANNOT: + - Access parent window (except via postMessage) - Navigate top window - Access local storage (unless same-origin) @@ -76,6 +79,7 @@ Without explicit permission, the iframe CANNOT: ### What's Allowed With current sandbox settings: + - Run JavaScript - Make network requests (same-origin) - Use Canvas and WebGL @@ -93,11 +97,13 @@ http://localhost:22500/[relativePath] ``` Example: + ``` http://localhost:22500/Games/HTML5/example-game/index.html ``` The game-service handles: + - Serving index.html - Serving game assets (JS, CSS, images) - CORS headers @@ -143,16 +149,19 @@ const [iframeError, setIframeError] = useState(null); ### Common Issues **Issue: Game doesn't load** + - Check browser console for CORS errors - Verify content URL is correct - Ensure game-service is running **Issue: Game loads but doesn't work** + - Check if game requires additional permissions - Verify sandbox attributes allow required features - Check browser console for JavaScript errors **Issue: Game can't access resources** + - CORS may be blocking cross-origin requests - Game may need same-origin access - Check game-service proxy configuration @@ -160,6 +169,7 @@ const [iframeError, setIframeError] = useState(null); ## Performance HTML5 games generally perform better than Flash: + - No emulation overhead - Native browser rendering - Direct access to WebGL @@ -190,10 +200,13 @@ Games can communicate with the parent window using postMessage: ```javascript // Inside game iframe -window.parent.postMessage({ - type: 'GAME_LOADED', - data: { gameId: '123' } -}, '*'); +window.parent.postMessage( + { + type: 'GAME_LOADED', + data: { gameId: '123' }, + }, + '*' +); // In parent window window.addEventListener('message', (event) => { @@ -206,6 +219,7 @@ window.addEventListener('message', (event) => { ## Browser Compatibility HTML5 games work in all modern browsers: + - Chrome/Edge (Chromium) - Best performance - Firefox - Good performance - Safari - Good performance (some WebGL limitations) diff --git a/docs/04-frontend/player-implementation/ruffle-player.md b/docs/04-frontend/player-implementation/ruffle-player.md index 04e3693..d4c467e 100644 --- a/docs/04-frontend/player-implementation/ruffle-player.md +++ b/docs/04-frontend/player-implementation/ruffle-player.md @@ -4,7 +4,8 @@ Comprehensive documentation for the Ruffle Flash emulator component. ## Overview -Ruffle is a Flash Player emulator written in Rust and compiled to WebAssembly. It allows Flash (.swf) content to run in modern browsers without plugins. +Ruffle is a Flash Player emulator written in Rust and compiled to WebAssembly. +It allows Flash (.swf) content to run in modern browsers without plugins. **Location:** `frontend/src/components/player/RufflePlayer.tsx` @@ -51,12 +52,12 @@ console.log('✅ Ruffle files copied to public/ruffle/'); ```typescript export interface RufflePlayerProps { - swfUrl: string; // URL to .swf file - width?: string | number; // Player width (default: '100%') - height?: string | number; // Player height (default: '100%') - className?: string; // Additional CSS classes - onLoadError?: (error: Error) => void; // Error callback - onLoadSuccess?: () => void; // Success callback + swfUrl: string; // URL to .swf file + width?: string | number; // Player width (default: '100%') + height?: string | number; // Player height (default: '100%') + className?: string; // Additional CSS classes + onLoadError?: (error: Error) => void; // Error callback + onLoadSuccess?: () => void; // Success callback } ``` @@ -64,22 +65,22 @@ export interface RufflePlayerProps { ```typescript player.config = { - autoplay: 'auto', // Auto-start playback - backgroundColor: '#000000', // Black background - letterbox: 'on', // Add letterboxing for aspect ratio - unmuteOverlay: 'visible', // Show unmute button if needed - contextMenu: true, // Enable right-click menu - showSwfDownload: false, // Hide download option + autoplay: 'auto', // Auto-start playback + backgroundColor: '#000000', // Black background + letterbox: 'on', // Add letterboxing for aspect ratio + unmuteOverlay: 'visible', // Show unmute button if needed + contextMenu: true, // Enable right-click menu + showSwfDownload: false, // Hide download option upgradeToHttps: window.location.protocol === 'https:', warnOnUnsupportedContent: true, - logLevel: 'error', // Only log errors - publicPath: '/ruffle/', // Path to Ruffle files - scale: 'showAll', // Show all content, maintain aspect - forceScale: true, // Prevent runtime scale changes - quality: 'high', // High quality rendering + logLevel: 'error', // Only log errors + publicPath: '/ruffle/', // Path to Ruffle files + scale: 'showAll', // Show all content, maintain aspect + forceScale: true, // Prevent runtime scale changes + quality: 'high', // High quality rendering allowScriptAccess: 'sameDomain', - salign: '', // Center alignment - wmode: 'opaque', // Opaque mode + salign: '', // Center alignment + wmode: 'opaque', // Opaque mode }; ``` @@ -88,22 +89,26 @@ player.config = { Ruffle supports multiple scale modes: ### showAll (Default) + - Shows entire content - Maintains aspect ratio - Adds letterboxing if needed - **Best for most games** ### noscale + - No scaling - 1:1 pixel mapping - May cut off content ### exactfit + - Scales to fill container - Ignores aspect ratio - May distort content ### noborder + - Scales to fill container - Maintains aspect ratio - Crops content if needed @@ -113,6 +118,7 @@ Ruffle supports multiple scale modes: The component implements a robust cleanup strategy to prevent memory leaks: ### 1. Mount Flag + ```typescript let mounted = true; @@ -127,6 +133,7 @@ useEffect(() => { ``` ### 2. Initialization Lock + ```typescript const isInitializingRef = useRef(false); @@ -139,14 +146,16 @@ isInitializingRef.current = true; ``` ### 3. Delay Before Initialization + ```typescript // Wait for any previous cleanup to complete -await new Promise(resolve => setTimeout(resolve, 100)); +await new Promise((resolve) => setTimeout(resolve, 100)); if (!mounted) return; ``` ### 4. Cleanup Existing Player + ```typescript if (rufflePlayerRef.current) { const oldPlayer = rufflePlayerRef.current; @@ -159,11 +168,12 @@ if (rufflePlayerRef.current) { } // Wait for Ruffle's internal cleanup - await new Promise(resolve => setTimeout(resolve, 200)); + await new Promise((resolve) => setTimeout(resolve, 200)); } ``` ### 5. Unmount Cleanup + ```typescript return () => { mounted = false; @@ -191,6 +201,7 @@ return () => { ## Loading States ### Loading UI + ```typescript {isLoading && (
@@ -204,6 +215,7 @@ return () => { ``` ### Error UI + ```typescript {error && (
@@ -249,15 +261,19 @@ function FlashGame() { ## Common Issues ### Issue: Game doesn't scale properly + **Solution:** Use `scale: 'showAll'` and `forceScale: true` ### Issue: Multiple player instances + **Solution:** Component includes cleanup delays and initialization locks ### Issue: Memory leaks on navigation + **Solution:** Component destroys player and clears container on unmount ### Issue: Black screen on load + **Solution:** Check browser console for errors, ensure SWF URL is correct ## Browser Console Logging @@ -284,6 +300,7 @@ The component includes extensive logging for debugging: ## Ruffle Limitations Not all Flash content is supported: + - Some ActionScript 3 features are incomplete - 3D content may have issues - Video playback may be limited diff --git a/docs/04-frontend/state-management/react-query.md b/docs/04-frontend/state-management/react-query.md index 9fa3a0f..2364b4a 100644 --- a/docs/04-frontend/state-management/react-query.md +++ b/docs/04-frontend/state-management/react-query.md @@ -4,7 +4,8 @@ Documentation for server state management using TanStack Query v5. ## Overview -TanStack Query handles all server-side data (games, playlists, users) with automatic caching, background refetching, and request deduplication. +TanStack Query handles all server-side data (games, playlists, users) with +automatic caching, background refetching, and request deduplication. **Version:** 5.62.7 @@ -44,7 +45,7 @@ export function useGames(filters: GameFilters) { return useQuery({ queryKey: ['games', filters], queryFn: () => gamesApi.search(filters), - staleTime: 5 * 60 * 1000 + staleTime: 5 * 60 * 1000, }); } ``` @@ -55,16 +56,16 @@ Query keys determine caching and invalidation: ```typescript // Simple key -queryKey: ['games'] +queryKey: ['games']; // With parameters -queryKey: ['games', filters] +queryKey: ['games', filters]; // Nested resources -queryKey: ['game', gameId, 'related'] +queryKey: ['game', gameId, 'related']; // Complex keys -queryKey: ['users', { page, limit, search }] +queryKey: ['users', { page, limit, search }]; ``` ## Mutations @@ -86,7 +87,7 @@ export function useCreatePlaylist() { }, onError: (error) => { toast.error('Failed to create playlist'); - } + }, }); } ``` @@ -122,7 +123,7 @@ export function useToggleFavorite() { onSettled: () => { // Refetch after mutation queryClient.invalidateQueries({ queryKey: ['favorites'] }); - } + }, }); } ``` @@ -140,14 +141,14 @@ Different data has different stale times: useQuery({ queryKey: ['games', id], queryFn: () => gamesApi.getById(id), - staleTime: 5 * 60 * 1000 // 5 minutes + staleTime: 5 * 60 * 1000, // 5 minutes }); // Short stale time for dynamic data useQuery({ queryKey: ['stats'], queryFn: () => statsApi.get(), - staleTime: 30 * 1000 // 30 seconds + staleTime: 30 * 1000, // 30 seconds }); ``` @@ -159,7 +160,7 @@ Conditional query execution: useQuery({ queryKey: ['game', id], queryFn: () => gamesApi.getById(id), - enabled: !!id // Only run if id exists + enabled: !!id, // Only run if id exists }); ``` @@ -170,10 +171,11 @@ For pagination: ```typescript useInfiniteQuery({ queryKey: ['games', filters], - queryFn: ({ pageParam = 1 }) => gamesApi.search({ ...filters, page: pageParam }), + queryFn: ({ pageParam = 1 }) => + gamesApi.search({ ...filters, page: pageParam }), getNextPageParam: (lastPage) => { return lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined; - } + }, }); ``` diff --git a/docs/04-frontend/state-management/url-state.md b/docs/04-frontend/state-management/url-state.md index fe5bdac..a49acbb 100644 --- a/docs/04-frontend/state-management/url-state.md +++ b/docs/04-frontend/state-management/url-state.md @@ -5,11 +5,13 @@ Documentation for managing filter and pagination state in URL search params. ## Overview The application uses URL search parameters (query strings) to manage: + - Filters (platform, tags, search, etc.) - Pagination (page, limit) - Sort options **Benefits:** + - Shareable URLs - Browser back/forward support - Bookmark-able search results @@ -64,7 +66,7 @@ Filters in URL sync with TanStack Query: const { data, isLoading } = useGames({ platform: searchParams.get('platform'), search: searchParams.get('search'), - page: parseInt(searchParams.get('page') || '1') + page: parseInt(searchParams.get('page') || '1'), }); ``` @@ -77,6 +79,7 @@ const { data, isLoading } = useGames({ ``` Parameters: + - `platform` - Filter by platform (Flash, HTML5, etc.) - `search` - Search query - `page` - Current page number @@ -115,7 +118,7 @@ function getFiltersFromURL(searchParams: URLSearchParams): GameFilters { order: searchParams.get('order') || undefined, tags: searchParams.get('tags')?.split(',') || undefined, playMode: searchParams.get('playMode') || undefined, - library: searchParams.get('library') || undefined + library: searchParams.get('library') || undefined, }; } ``` @@ -180,6 +183,7 @@ function Pagination({ total, page, limit }: PaginationProps) { ### Shareable Links Users can share exact search results: + ``` https://flashpoint.app/browse?platform=Flash&search=pokemon&sort=title ``` @@ -187,6 +191,7 @@ https://flashpoint.app/browse?platform=Flash&search=pokemon&sort=title ### Browser Navigation Back/forward buttons work naturally: + - Back: Previous filter state - Forward: Next filter state @@ -199,6 +204,7 @@ Users can bookmark specific searches and return to exact state. ### URL Length Limits Be cautious with very long filter combinations. Consider: + - Limiting number of tags - Using shortened parameter names - Storing complex filters server-side diff --git a/docs/04-frontend/state-management/zustand-stores.md b/docs/04-frontend/state-management/zustand-stores.md index 2f05532..0b3e027 100644 --- a/docs/04-frontend/state-management/zustand-stores.md +++ b/docs/04-frontend/state-management/zustand-stores.md @@ -1,22 +1,26 @@ # Zustand Stores -This document describes the Zustand stores used for client-side state management in the Flashpoint Web frontend. +This document describes the Zustand stores used for client-side state management +in the Flashpoint Web frontend. ## Overview -The application uses three main Zustand stores for different aspects of UI state: +The application uses three main Zustand stores for different aspects of UI +state: 1. **useAuthStore** - Authentication state, JWT tokens, user permissions 2. **useThemeStore** - Theme mode (light/dark/system), primary color 3. **useUIStore** - Sidebar visibility, view modes, card sizes -All stores use the `persist` middleware to save state to localStorage (or sessionStorage for guest mode). +All stores use the `persist` middleware to save state to localStorage (or +sessionStorage for guest mode). ## useAuthStore Manages authentication state, user information, and JWT tokens. ### Location + `D:\Repositories\Personal\flashpoint-web\frontend\src\store\auth.ts` ### State @@ -77,7 +81,7 @@ const handleLogin = async (credentials) => { const response = await authApi.login(credentials); setAuth(response.user, { accessToken: response.accessToken, - refreshToken: response.refreshToken + refreshToken: response.refreshToken, }); }; ``` @@ -169,7 +173,8 @@ if (hasAllPermissions(['users.read', 'users.write', 'users.delete'])) { ### Persistence Strategy -The auth store uses a custom storage strategy that separates guest and authenticated sessions: +The auth store uses a custom storage strategy that separates guest and +authenticated sessions: ```typescript storage: createJSONStorage(() => ({ @@ -199,18 +204,20 @@ storage: createJSONStorage(() => ({ removeItem: (name) => { localStorage.removeItem(name); sessionStorage.removeItem(name); - } -})) + }, +})); ``` **Benefits:** + - Guest sessions cleared on browser close (sessionStorage) - Authenticated sessions persist across browser restarts (localStorage) - Prevents guest data from polluting localStorage ### Theme Loading on Login -After successful login, the auth store automatically loads theme settings from the server: +After successful login, the auth store automatically loads theme settings from +the server: ```typescript setAuth: (user: User, tokens: AuthTokens) => { @@ -219,7 +226,7 @@ setAuth: (user: User, tokens: AuthTokens) => { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, isAuthenticated: true, - isGuest: false + isGuest: false, }); // Load theme settings from server @@ -227,7 +234,7 @@ setAuth: (user: User, tokens: AuthTokens) => { themeStore.loadThemeFromServer().catch((error) => { console.debug('Failed to load theme settings after login:', error); }); -} +}; ``` ## useThemeStore @@ -235,14 +242,15 @@ setAuth: (user: User, tokens: AuthTokens) => { Manages theme mode (light/dark/system) and primary color selection. ### Location + `D:\Repositories\Personal\flashpoint-web\frontend\src\store\theme.ts` ### State ```typescript interface ThemeState { - mode: ThemeMode; // 'light' | 'dark' | 'system' - primaryColor: PrimaryColor; // 21 color options + mode: ThemeMode; // 'light' | 'dark' | 'system' + primaryColor: PrimaryColor; // 21 color options isLoading: boolean; setMode: (mode: ThemeMode) => void; @@ -258,19 +266,42 @@ interface ThemeState { ```typescript export type PrimaryColor = - | 'blue' // Default - | 'slate' | 'gray' | 'zinc' | 'neutral' | 'stone' - | 'red' | 'orange' | 'amber' | 'yellow' - | 'lime' | 'green' | 'emerald' | 'teal' | 'cyan' - | 'sky' | 'indigo' | 'violet' | 'purple' - | 'fuchsia' | 'pink' | 'rose'; + | 'blue' // Default + | 'slate' + | 'gray' + | 'zinc' + | 'neutral' + | 'stone' + | 'red' + | 'orange' + | 'amber' + | 'yellow' + | 'lime' + | 'green' + | 'emerald' + | 'teal' + | 'cyan' + | 'sky' + | 'indigo' + | 'violet' + | 'purple' + | 'fuchsia' + | 'pink' + | 'rose'; ``` Each color has light and dark mode variants stored as HSL values: ```typescript -export const colorPalette: Record = { - blue: { light: '221.2 83.2% 53.3%', dark: '217.2 91.2% 59.8%', label: 'Blue' }, +export const colorPalette: Record< + PrimaryColor, + { light: string; dark: string; label: string } +> = { + blue: { + light: '221.2 83.2% 53.3%', + dark: '217.2 91.2% 59.8%', + label: 'Blue', + }, red: { light: '0 72.2% 50.6%', dark: '0 72.2% 60.6%', label: 'Red' }, // ... 19 more colors }; @@ -303,10 +334,11 @@ Changes theme mode and applies to document: ```typescript const { setMode } = useThemeStore(); -setMode('dark'); // Immediately applies dark theme +setMode('dark'); // Immediately applies dark theme ``` Automatically: + - Updates CSS classes on `` element - Reapplies primary color for new theme - Syncs to server (if authenticated) @@ -318,10 +350,11 @@ Changes primary color and applies to CSS variables: ```typescript const { setPrimaryColor } = useThemeStore(); -setPrimaryColor('emerald'); // Applies emerald color theme +setPrimaryColor('emerald'); // Applies emerald color theme ``` Automatically: + - Updates `--primary` and `--ring` CSS variables - Syncs to server (if authenticated) @@ -336,6 +369,7 @@ await loadThemeFromServer(); ``` Called automatically: + - After successful login - On app rehydration (if authenticated) @@ -350,6 +384,7 @@ await syncThemeToServer(); ``` Called automatically: + - When mode changes - When primary color changes - Silent failure if not authenticated @@ -393,13 +428,15 @@ const applyPrimaryColor = (color: PrimaryColor, currentMode: ThemeMode) => { Listens for system theme changes when in 'system' mode: ```typescript -window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { - const state = useThemeStore.getState(); - if (state.mode === 'system') { - applyTheme('system'); - applyPrimaryColor(state.primaryColor, 'system'); - } -}); +window + .matchMedia('(prefers-color-scheme: dark)') + .addEventListener('change', () => { + const state = useThemeStore.getState(); + if (state.mode === 'system') { + applyTheme('system'); + applyPrimaryColor(state.primaryColor, 'system'); + } + }); ``` ### Persistence @@ -408,12 +445,14 @@ Theme state is persisted to localStorage: ```typescript persist( - (set, get) => ({ /* ... */ }), + (set, get) => ({ + /* ... */ + }), { name: 'flashpoint-theme-settings', partialize: (state) => ({ mode: state.mode, - primaryColor: state.primaryColor + primaryColor: state.primaryColor, }), onRehydrateStorage: () => (state) => { if (state) { @@ -426,9 +465,9 @@ persist( console.debug('Failed to load theme from server:', error); }); } - } + }, } -) +); ``` ## useUIStore @@ -436,6 +475,7 @@ persist( Manages UI state like sidebar visibility, view modes, and display preferences. ### Location + `D:\Repositories\Personal\flashpoint-web\frontend\src\store\ui.ts` ### State @@ -444,10 +484,10 @@ Manages UI state like sidebar visibility, view modes, and display preferences. interface UIState { sidebarOpen: boolean; sidebarCollapsed: boolean; - viewMode: ViewMode; // 'grid' | 'list' + viewMode: ViewMode; // 'grid' | 'list' selectedGameId: string | null; - cardSize: CardSize; // 'small' | 'medium' | 'large' - listColumns: ListColumns; // 1 | 2 | 3 | 4 + cardSize: CardSize; // 'small' | 'medium' | 'large' + listColumns: ListColumns; // 1 | 2 | 3 | 4 toggleSidebar: () => void; setSidebarOpen: (open: boolean) => void; @@ -496,7 +536,7 @@ const { setSidebarOpen, sidebarCollapsed, toggleSidebarCollapsed, - setSidebarCollapsed + setSidebarCollapsed, } = useUIStore(); // Toggle sidebar open/closed (mobile) @@ -514,8 +554,8 @@ toggleSidebarCollapsed(); ```typescript const { viewMode, setViewMode } = useUIStore(); -setViewMode('grid'); // Switch to grid view -setViewMode('list'); // Switch to list view +setViewMode('grid'); // Switch to grid view +setViewMode('list'); // Switch to list view ``` #### Card Size Actions @@ -523,9 +563,9 @@ setViewMode('list'); // Switch to list view ```typescript const { cardSize, setCardSize } = useUIStore(); -setCardSize('small'); // Smaller cards -setCardSize('medium'); // Medium cards (default) -setCardSize('large'); // Larger cards +setCardSize('small'); // Smaller cards +setCardSize('medium'); // Medium cards (default) +setCardSize('large'); // Larger cards ``` #### List Columns Actions @@ -533,10 +573,10 @@ setCardSize('large'); // Larger cards ```typescript const { listColumns, setListColumns } = useUIStore(); -setListColumns(1); // Single column -setListColumns(2); // Two columns -setListColumns(3); // Three columns -setListColumns(4); // Four columns +setListColumns(1); // Single column +setListColumns(2); // Two columns +setListColumns(3); // Three columns +setListColumns(4); // Four columns ``` ### Default Values @@ -558,7 +598,9 @@ UI state is persisted to localStorage: ```typescript persist( - (set) => ({ /* ... */ }), + (set) => ({ + /* ... */ + }), { name: 'flashpoint-ui-settings', partialize: (state) => ({ @@ -566,11 +608,11 @@ persist( sidebarCollapsed: state.sidebarCollapsed, cardSize: state.cardSize, viewMode: state.viewMode, - listColumns: state.listColumns + listColumns: state.listColumns, // selectedGameId is intentionally not persisted - }) + }), } -) +); ``` ## Best Practices @@ -601,7 +643,7 @@ const token = useAuthStore.getState().accessToken; Zustand automatically batches state updates: ```typescript -setAuth(user, tokens); // Single update, single re-render +setAuth(user, tokens); // Single update, single re-render ``` ### 4. DevTools Integration @@ -612,10 +654,7 @@ Enable Redux DevTools for debugging: import { devtools } from 'zustand/middleware'; export const useAuthStore = create()( - devtools( - persist(/* ... */), - { name: 'Auth Store' } - ) + devtools(persist(/* ... */), { name: 'Auth Store' }) ); ``` diff --git a/docs/04-frontend/views-routing.md b/docs/04-frontend/views-routing.md index 518bee2..58f1181 100644 --- a/docs/04-frontend/views-routing.md +++ b/docs/04-frontend/views-routing.md @@ -1,6 +1,7 @@ # Views and Routing -Complete documentation of all views, routes, and route protection in the Flashpoint Web frontend. +Complete documentation of all views, routes, and route protection in the +Flashpoint Web frontend. ## Route Structure @@ -42,16 +43,17 @@ All routes use the `ProtectedRoute` component for authorization: ```typescript interface ProtectedRouteProps { children: React.ReactNode; - requireAuth?: boolean; // Default: true - requirePermission?: string; // Single permission - requireAnyPermission?: string[]; // Any of these - requireAllPermissions?: string[]; // All of these + requireAuth?: boolean; // Default: true + requirePermission?: string; // Single permission + requireAnyPermission?: string[]; // Any of these + requireAllPermissions?: string[]; // All of these } ``` ### Protection Levels **1. Public Routes** - `requireAuth={false}` + ```typescript @@ -61,6 +63,7 @@ interface ProtectedRouteProps { ``` **2. Authenticated Routes** - Default behavior + ```typescript @@ -70,6 +73,7 @@ interface ProtectedRouteProps { ``` **3. Permission-Based Routes** + ```typescript @@ -85,6 +89,7 @@ interface ProtectedRouteProps { Pre-filtered game browsing views. **Features:** + - Platform-specific filtering - Grid/List view toggle - Pagination @@ -95,6 +100,7 @@ Pre-filtered game browsing views. Main game browsing view with full filtering. **Features:** + - Multi-filter sidebar (Platform, Tags, Play Mode, Library) - Search bar - Sort options @@ -107,6 +113,7 @@ Main game browsing view with full filtering. Detailed game information page. **Features:** + - Game metadata (title, developer, publisher, release date) - Screenshots and logos - Tags and categories @@ -119,6 +126,7 @@ Detailed game information page. Full-screen game player. **Features:** + - Flash/HTML5 game rendering - Fullscreen mode - Play session tracking @@ -130,6 +138,7 @@ Full-screen game player. User statistics dashboard. **Features:** + - Total games played - Total playtime - Top games chart @@ -142,6 +151,7 @@ User statistics dashboard. User playlists management. **Features:** + - Create new playlists - Browse community playlists - Delete playlists @@ -152,6 +162,7 @@ User playlists management. Admin management views. **Features:** + - User management (create, edit, delete, change password) - Role management with permission assignment - Activity logs with filtering diff --git a/docs/05-game-service/README.md b/docs/05-game-service/README.md index bd9d4fa..46ef3a2 100644 --- a/docs/05-game-service/README.md +++ b/docs/05-game-service/README.md @@ -1,12 +1,15 @@ # Game Service -The Game Service is an independent Node.js service that provides game content delivery with intelligent fallback mechanisms for legacy web content and ZIP archive serving. +The Game Service is an independent Node.js service that provides game content +delivery with intelligent fallback mechanisms for legacy web content and ZIP +archive serving. ## Overview The game-service runs two HTTP servers: -1. **HTTP Proxy Server** (Port 22500) - Legacy web content serving with fallback chain +1. **HTTP Proxy Server** (Port 22500) - Legacy web content serving with fallback + chain 2. **GameZip Server** (Port 22501) - ZIP archive streaming ## Architecture @@ -27,7 +30,8 @@ The game-service runs two HTTP servers: ## Key Features -- **Multi-source fallback**: Local htdocs → Game data → ZIP archives → External CDN +- **Multi-source fallback**: Local htdocs → Game data → ZIP archives → External + CDN - **Zero-extraction ZIP serving**: Files streamed directly from archives - **MIME type detection**: 199+ file types including legacy formats - **HTML polyfill injection**: Automatic compatibility fixes @@ -74,29 +78,31 @@ curl -X POST http://localhost:22501/mount/game-123 \ ## Service Ports | Port | Service | Purpose | -|-------|----------------|----------------------------------| +| ----- | -------------- | -------------------------------- | | 22500 | HTTP Proxy | Legacy web content and fallbacks | | 22501 | GameZip Server | ZIP archive file serving | ## Environment Variables -| Variable | Default | Description | -|-------------------------|------------------------------|--------------------------| -| PROXY_PORT | 22500 | HTTP proxy server port | -| GAMEZIPSERVER_PORT | 22501 | GameZip server port | -| FLASHPOINT_PATH | D:/Flashpoint | Flashpoint installation | -| EXTERNAL_FALLBACK_URLS | infinity.flashpointarchive | CDN URLs (comma-separated)| -| LOG_LEVEL | info | Logging verbosity | +| Variable | Default | Description | +| ---------------------- | -------------------------- | -------------------------- | +| PROXY_PORT | 22500 | HTTP proxy server port | +| GAMEZIPSERVER_PORT | 22501 | GameZip server port | +| FLASHPOINT_PATH | D:/Flashpoint | Flashpoint installation | +| EXTERNAL_FALLBACK_URLS | infinity.flashpointarchive | CDN URLs (comma-separated) | +| LOG_LEVEL | info | Logging verbosity | ## Request Flow ### HTTP Proxy (Port 22500) + 1. Try GameZip server (if ZIP mounted) 2. Try local htdocs 3. Try external CDN fallback 4. Return 404 if not found ### GameZip Server (Port 22501) + 1. Mount ZIP via POST /mount/game-id 2. Request file: GET /http://domain.com/path/file.swf 3. Search mounted ZIPs for content @@ -113,6 +119,7 @@ curl -X POST http://localhost:22501/mount/game-123 \ ## Integration with Backend The backend delegates file serving to game-service: + - Backend returns launch URLs pointing to proxy server - Frontend loads game content from game-service URLs - Game-service operates independently without backend communication @@ -127,6 +134,7 @@ The backend delegates file serving to game-service: ## Troubleshooting ### Port Already in Use + ```bash # Find process using port (Windows) netstat -ano | findstr :22500 @@ -136,12 +144,14 @@ PROXY_PORT=22510 ``` ### File Not Found + 1. Verify Flashpoint path is correct 2. Check if file exists in htdocs directory 3. Verify external fallback URLs are accessible 4. Ensure ZIP is mounted via GameZip API ### CORS Errors + - Verify `ALLOW_CROSS_DOMAIN=true` in `.env` - Check response headers: `curl -I ` - Ensure error responses include CORS headers @@ -159,7 +169,8 @@ docker-compose up -d game-service docker-compose logs -f game-service ``` -Set `FLASHPOINT_HOST_PATH` environment variable to point to your Flashpoint installation. +Set `FLASHPOINT_HOST_PATH` environment variable to point to your Flashpoint +installation. ## Production Deployment diff --git a/docs/05-game-service/architecture.md b/docs/05-game-service/architecture.md index a74ed81..89fce42 100644 --- a/docs/05-game-service/architecture.md +++ b/docs/05-game-service/architecture.md @@ -1,15 +1,20 @@ # Game Service Architecture -This document describes the architectural design and implementation patterns of the Flashpoint Game Service. +This document describes the architectural design and implementation patterns of +the Flashpoint Game Service. ## System Overview -The game-service is designed as a standalone microservice that handles all game content delivery, replacing the original Go-based FlashpointGameServer with a TypeScript/Node.js implementation. +The game-service is designed as a standalone microservice that handles all game +content delivery, replacing the original Go-based FlashpointGameServer with a +TypeScript/Node.js implementation. ### Design Principles -1. **Separation of Concerns**: Game content serving separated from metadata management -2. **Zero Extraction**: ZIP files served via streaming, no disk extraction required +1. **Separation of Concerns**: Game content serving separated from metadata + management +2. **Zero Extraction**: ZIP files served via streaming, no disk extraction + required 3. **Fallback Chain**: Multi-level fallback ensures content availability 4. **Performance**: Streaming, connection pooling, and caching for optimal speed 5. **Compatibility**: 199+ MIME types and HTML polyfills for legacy content @@ -64,11 +69,13 @@ The game-service is designed as a standalone microservice that handles all game **Purpose**: Serve legacy web content with intelligent fallback chain **Components**: + - `http-proxy-server.ts`: Express server setup and configuration - `proxy-request-handler.ts`: Request routing and response handling - `legacy-server.ts`: Fallback chain implementation **Request Flow**: + ``` Request ↓ @@ -84,6 +91,7 @@ Return response with CORS headers ``` **Key Features**: + - Supports three URL formats: proxy-style, path-based, standard - Automatic subdomain variation matching (www, core, cdn, etc.) - External CDN fallback with local caching @@ -94,10 +102,12 @@ Return response with CORS headers **Purpose**: Mount and serve files from ZIP archives without extraction **Components**: + - `gamezipserver.ts`: GameZip server implementation - `zip-manager.ts`: ZIP mounting, indexing, and file access **API Endpoints**: + ``` POST /mount/:id Mount a ZIP file DELETE /mount/:id Unmount a ZIP file @@ -106,6 +116,7 @@ GET /* Serve file from mounted ZIP ``` **Request Flow**: + ``` Mount Request ↓ @@ -131,6 +142,7 @@ Return with MIME type + CORS ``` **Key Features**: + - Zero-extraction ZIP access using node-stream-zip - Multi-ZIP search with path variation handling - Streaming for large files @@ -141,19 +153,22 @@ Return with MIME type + CORS **Purpose**: Centralized configuration from environment and proxySettings.json **Sources** (in priority order): + 1. Environment variables (.env) 2. proxySettings.json (Flashpoint standard config) 3. Hardcoded defaults **Configuration Loading**: + ```typescript class ConfigManager { - static async loadConfig(flashpointPath: string): Promise - static getSettings(): ServerSettings + static async loadConfig(flashpointPath: string): Promise; + static getSettings(): ServerSettings; } ``` **Settings Managed**: + - Server ports - File paths (htdocs, games, CGI) - External fallback URLs @@ -165,6 +180,7 @@ class ConfigManager { **Purpose**: Implement multi-level fallback for legacy content serving **Fallback Chain**: + ``` 1. Local htdocs (exact hostname + path) ↓ Not found @@ -186,6 +202,7 @@ class ConfigManager { ``` **Path Resolution Algorithm**: + ```typescript buildPathCandidates(relPath, urlPath): // 1. Exact paths @@ -211,33 +228,37 @@ buildPathCandidates(relPath, urlPath): **Hostname Variation Matching**: For a request to `mochibot.com/file.swf`, try: + 1. `mochibot.com/file.swf` 2. `www.mochibot.com/file.swf` 3. `core.mochibot.com/file.swf` 4. `cdn.mochibot.com/file.swf` 5. ... (12 common subdomain prefixes) -This handles cases where Flashpoint stores files with subdomain prefixes but games request without them. +This handles cases where Flashpoint stores files with subdomain prefixes but +games request without them. ### 5. ZIP Manager **Purpose**: Manage ZIP mounting and provide file access without extraction **Architecture**: + ```typescript class ZipManager { - private mountedZips: LRUCache + private mountedZips: LRUCache; - async mount(id: string, zipPath: string): void - async unmount(id: string): Promise - async getFile(id: string, filePath: string): Promise - async findFile(relPath: string): Promise<{data, mountId} | null> + async mount(id: string, zipPath: string): void; + async unmount(id: string): Promise; + async getFile(id: string, filePath: string): Promise; + async findFile(relPath: string): Promise<{ data; mountId } | null>; } ``` **Performance Optimization - LRU Cache**: -The ZIP manager uses an LRU (Least Recently Used) cache to prevent memory leaks and manage resources: +The ZIP manager uses an LRU (Least Recently Used) cache to prevent memory leaks +and manage resources: ```typescript constructor() { @@ -256,6 +277,7 @@ constructor() { ``` **Benefits**: + - Prevents unlimited ZIP mounts (bounded at 100) - Automatic cleanup of old mounts after 30 minutes - Resource leak prevention via disposal callback @@ -263,6 +285,7 @@ constructor() { - Graceful eviction when limit reached **Mounting Process**: + ``` 1. Check if already mounted (prevent duplicates) 2. Verify ZIP file exists on disk @@ -272,6 +295,7 @@ constructor() { ``` **File Access**: + ``` 1. Normalize file path (remove leading slash) 2. Look up mounted ZIP by ID @@ -280,6 +304,7 @@ constructor() { ``` **Multi-ZIP Search**: + ``` Try path variations in order: - content/{relPath} (most common) @@ -297,6 +322,7 @@ For each variation: **Purpose**: Provide accurate content-type headers for 199+ file types **Architecture**: + ```typescript CUSTOM_MIME_TYPES: 199 legacy formats STANDARD_MIME_TYPES: Common web formats @@ -309,16 +335,18 @@ getMimeType(ext: string): string { ``` **Priority**: + 1. Custom MIME types (Flashpoint legacy formats) 2. Standard MIME types (web formats) 3. Default: application/octet-stream **Special Cases**: + - Director files (.dcr, .dir, .dxr) → application/x-director - Shockwave Flash (.swf) → application/x-shockwave-flash - Unity3D (.unity3d) → application/vnd.unity - VRML (.wrl) → model/vrml -- Chemical formats (.pdb, .mol) → chemical/x-* +- Chemical formats (.pdb, .mol) → chemical/x-\* ### 7. CORS Utilities @@ -327,6 +355,7 @@ getMimeType(ext: string): string { **Location**: `game-service/src/utils/cors.ts` **Benefits**: + - Eliminates 6+ instances of duplicate CORS header setting - Consistent CORS configuration across both servers - Reusable functions for standard and preflight requests @@ -339,14 +368,14 @@ interface CorsSettings { } // Standard CORS headers -function setCorsHeaders(res: ServerResponse, settings: CorsSettings): void +function setCorsHeaders(res: ServerResponse, settings: CorsSettings): void; // CORS headers with max-age (for OPTIONS preflight) function setCorsHeadersWithMaxAge( res: ServerResponse, settings: CorsSettings, maxAge: number = 86400 -): void +): void; ``` **Usage**: @@ -372,14 +401,17 @@ if (req.method === 'OPTIONS') { **Security Justification**: -The game-service uses wildcard CORS (`Access-Control-Allow-Origin: *`) which is justified because: +The game-service uses wildcard CORS (`Access-Control-Allow-Origin: *`) which is +justified because: + - Serves public, read-only game content only - No sensitive data exposure - No authentication required - Supports game embedding across different domains - Backend service maintains restrictive CORS for sensitive operations -See `docs/12-reference/cors-security-decision.md` for detailed security rationale. +See `docs/12-reference/cors-security-decision.md` for detailed security +rationale. ### 8. HTML Polyfill Injector @@ -397,6 +429,7 @@ See `docs/12-reference/cors-security-decision.md` for detailed security rational - `AudioContext` polyfill - WebKit prefix handling **Injection Algorithm**: + ``` 1. Detect if HTML file (check for or ) 2. Analyze content for Unity indicators @@ -408,6 +441,7 @@ See `docs/12-reference/cors-security-decision.md` for detailed security rational ``` **Detection Patterns**: + ```typescript needsUnityPolyfills(html: string): boolean { const indicators = [ @@ -526,10 +560,12 @@ needsUnityPolyfills(html: string): boolean { ### Horizontal Scaling Current limitations: + - ZIP mounts are in-memory per process - No shared state between instances Future improvements: + - Redis for mount registry - Consistent hashing for ZIP distribution - Load balancer for multiple instances @@ -537,23 +573,27 @@ Future improvements: ### Performance Tuning **Memory**: + - ZIP indexes are loaded into memory - Large ZIPs (>1GB) take ~500MB RAM for index - Solution: Lazy loading, LRU cache for ZIP handles **CPU**: + - MIME type lookup: O(1) hash map - Path candidates: O(n) where n = variations - ZIP search: O(m × p) where m = mounted ZIPs, p = path variations - Solution: Cache results, reduce variations **Network**: + - External downloads: 45s timeout - Concurrent limit: 10 requests - Keep-alive: 65s - Solution: Increase timeouts for slow connections **Disk I/O**: + - ZIP reads: Random access via node-stream-zip - Local cache: Sequential writes - Solution: SSD recommended, RAM disk for cache @@ -584,18 +624,19 @@ try { // Try operation } catch (error) { if (isRecoverable(error)) { - logger.warn('Recoverable error, trying next source', error) - continue // Try next fallback + logger.warn('Recoverable error, trying next source', error); + continue; // Try next fallback } else { - logger.error('Fatal error', error) - throw error // Return 500 + logger.error('Fatal error', error); + throw error; // Return 500 } } ``` ### CORS Error Handling -**Critical**: CORS headers MUST be set even for error responses, otherwise browsers show CORS error instead of actual error. +**Critical**: CORS headers MUST be set even for error responses, otherwise +browsers show CORS error instead of actual error. ```typescript import { setCorsHeaders } from '../utils/cors'; @@ -612,7 +653,8 @@ private sendError(res: Response, status: number, message: string) { ### Request Body Size Limits -**DoS Protection**: The GameZip server limits request body sizes to prevent memory exhaustion attacks: +**DoS Protection**: The GameZip server limits request body sizes to prevent +memory exhaustion attacks: ```typescript // gamezipserver.ts @@ -633,6 +675,7 @@ private readBody(req: http.IncomingMessage, maxSize: number = 1024 * 1024): Prom ``` **Protection**: + - 1MB maximum request size by default - Connection destroyed immediately on overflow - Prevents memory exhaustion attacks @@ -641,33 +684,45 @@ private readBody(req: http.IncomingMessage, maxSize: number = 1024 * 1024): Prom ### Path Traversal Prevention All file paths are normalized: + ```typescript -path.normalize(filePath).toLowerCase() +path.normalize(filePath).toLowerCase(); ``` **ZIP Mount ID Validation**: + ```typescript // Reject IDs with path traversal sequences -if (id.includes('/') || id.includes('\\') || - id.includes('..') || id.includes('\0')) { +if ( + id.includes('/') || + id.includes('\\') || + id.includes('..') || + id.includes('\0') +) { this.sendError(res, 400, 'Invalid mount ID'); return; } ``` **ZIP Path Validation**: + ```typescript // Ensure ZIP files are within allowed directory const resolvedZipPath = path.resolve(normalizedZipPath); const resolvedGamesPath = path.resolve(allowedGamesPath); if (!resolvedZipPath.startsWith(resolvedGamesPath)) { - this.sendError(res, 403, 'Forbidden: ZIP file must be within games directory'); + this.sendError( + res, + 403, + 'Forbidden: ZIP file must be within games directory' + ); return; } ``` Prevents attacks like: + - `../../../etc/passwd` - `..\\..\\windows\\system32` - Null byte injection (`file.txt\0.jpg`) @@ -698,12 +753,14 @@ Prevents attacks like: ### Logging Strategy **Levels**: + - DEBUG: Path candidates, ZIP searches, file access - INFO: Requests, mounts, file serves, sources - WARN: Fallback attempts, missing files - ERROR: Server errors, mount failures, fatal errors **Log Format**: + ``` [Component] Message [HTTPProxyServer] GET http://domain.com/file.swf @@ -715,6 +772,7 @@ Prevents attacks like: ### Metrics (Future) Potential metrics to track: + - Requests per second - Response time percentiles (p50, p95, p99) - Cache hit rate @@ -725,6 +783,7 @@ Potential metrics to track: ### Health Checks Endpoint suggestions: + ``` GET /health Basic health check GET /metrics Prometheus metrics diff --git a/docs/05-game-service/configuration.md b/docs/05-game-service/configuration.md index 8a4798d..3766191 100644 --- a/docs/05-game-service/configuration.md +++ b/docs/05-game-service/configuration.md @@ -1,6 +1,7 @@ # Configuration -The game-service configuration system uses environment variables and `proxySettings.json` to provide flexible deployment options. +The game-service configuration system uses environment variables and +`proxySettings.json` to provide flexible deployment options. ## Configuration Sources @@ -27,14 +28,12 @@ HTTP Proxy Server port. PROXY_PORT=22500 ``` -**Default**: 22500 -**Type**: Integer -**Range**: 1024-65535 -**Required**: No +**Default**: 22500 **Type**: Integer **Range**: 1024-65535 **Required**: No **Use case**: Change if port conflict occurs **Examples**: + ```bash PROXY_PORT=22500 # Default PROXY_PORT=8080 # Alternative HTTP port @@ -49,10 +48,7 @@ GameZip Server port. GAMEZIPSERVER_PORT=22501 ``` -**Default**: 22501 -**Type**: Integer -**Range**: 1024-65535 -**Required**: No +**Default**: 22501 **Type**: Integer **Range**: 1024-65535 **Required**: No **Use case**: Change if port conflict occurs @@ -66,13 +62,13 @@ Flashpoint installation root directory. FLASHPOINT_PATH=D:/Flashpoint ``` -**Default**: D:/Flashpoint -**Type**: Absolute path -**Required**: Yes (for production) +**Default**: D:/Flashpoint **Type**: Absolute path **Required**: Yes (for +production) **Use case**: Point to Flashpoint installation **Platform-specific**: + ```bash # Windows FLASHPOINT_PATH=D:/Flashpoint @@ -94,7 +90,8 @@ All other Flashpoint paths are automatically derived from `FLASHPOINT_PATH`: - HTDOCS: `${FLASHPOINT_PATH}/Legacy/htdocs` - Games: `${FLASHPOINT_PATH}/Data/Games` -You do not need to set environment variables for these paths - they are calculated automatically from `FLASHPOINT_PATH`. +You do not need to set environment variables for these paths - they are +calculated automatically from `FLASHPOINT_PATH`. ### Proxy Settings @@ -106,12 +103,11 @@ Streaming chunk size in bytes. PROXY_CHUNK_SIZE=8192 ``` -**Default**: 8192 (8KB) -**Type**: Integer -**Range**: 1024-1048576 (1KB-1MB) +**Default**: 8192 (8KB) **Type**: Integer **Range**: 1024-1048576 (1KB-1MB) **Required**: No **Performance tuning**: + ```bash PROXY_CHUNK_SIZE=4096 # Low memory, high CPU PROXY_CHUNK_SIZE=8192 # Balanced (default) @@ -119,6 +115,7 @@ PROXY_CHUNK_SIZE=65536 # High memory, low CPU ``` **Trade-offs**: + - Smaller chunks: Lower memory, higher CPU, more I/O calls - Larger chunks: Higher memory, lower CPU, fewer I/O calls @@ -130,19 +127,19 @@ Enable CORS headers. ALLOW_CROSS_DOMAIN=true ``` -**Default**: true -**Type**: Boolean (true/false) -**Required**: No +**Default**: true **Type**: Boolean (true/false) **Required**: No **Use case**: Disable for internal networks **Values**: + ```bash ALLOW_CROSS_DOMAIN=true # Enable CORS (default) ALLOW_CROSS_DOMAIN=false # Disable CORS ``` **Impact**: + - `true`: Adds `Access-Control-Allow-Origin: *` - `false`: No CORS headers, cross-domain requests blocked @@ -156,18 +153,19 @@ Comma-separated list of external CDN URLs. EXTERNAL_FALLBACK_URLS=http://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs,http://infinity.unstable.life/Flashpoint/Legacy/htdocs/ ``` -**Default**: infinity.flashpointarchive.org -**Type**: Comma-separated URLs +**Default**: infinity.flashpointarchive.org **Type**: Comma-separated URLs **Required**: No **Use case**: Add backup CDNs **Format**: + ```bash EXTERNAL_FALLBACK_URLS=https://cdn1.example.com,https://cdn2.example.com,https://cdn3.example.com ``` **Notes**: + - HTTP URLs auto-upgraded to HTTPS - Tried in order - First successful response used @@ -182,12 +180,11 @@ Logging verbosity level. LOG_LEVEL=info ``` -**Default**: info -**Type**: String -**Values**: error, warn, info, debug +**Default**: info **Type**: String **Values**: error, warn, info, debug **Required**: No **Levels**: + ```bash LOG_LEVEL=error # Errors only LOG_LEVEL=warn # Warnings and errors @@ -196,6 +193,7 @@ LOG_LEVEL=debug # Verbose logging ``` **Impact**: + - `error`: Minimal logs, production recommended - `warn`: Important messages - `info`: Request logging, file serves @@ -209,14 +207,13 @@ Node.js environment mode. NODE_ENV=development ``` -**Default**: development -**Type**: String -**Values**: development, production, test -**Required**: No +**Default**: development **Type**: String **Values**: development, production, +test **Required**: No **Use case**: Production optimization **Values**: + ```bash NODE_ENV=development # Development mode (default) NODE_ENV=production # Production optimizations @@ -233,9 +230,7 @@ Enable CGI script execution. ENABLE_CGI=false ``` -**Default**: false -**Type**: Boolean -**Required**: No +**Default**: false **Type**: Boolean **Required**: No **Use case**: Execute PHP/Perl scripts @@ -249,9 +244,7 @@ Enable .htaccess file parsing. ENABLE_HTACCESS=false ``` -**Default**: false -**Type**: Boolean -**Required**: No +**Default**: false **Type**: Boolean **Required**: No **Use case**: Apache-style directory configuration @@ -295,12 +288,12 @@ Override directories in htdocs. "legacyOverridePaths": ["custom", "fixes", "overrides"] ``` -**Default**: [] -**Type**: Array of strings +**Default**: [] **Type**: Array of strings **Use case**: Patch broken games with fixed versions **Path resolution**: + ``` Request: www.example.com/game.swf Override: htdocs/custom/www.example.com/game.swf @@ -315,8 +308,7 @@ Primary external CDN URL. "infinityServerURL": "https://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs/" ``` -**Default**: https://infinity.flashpointarchive.org/... -**Type**: String (URL) +**Default**: https://infinity.flashpointarchive.org/... **Type**: String (URL) **Use case**: Change primary CDN @@ -331,8 +323,7 @@ Additional external CDN URLs. ] ``` -**Default**: [] -**Type**: Array of URLs +**Default**: [] **Type**: Array of URLs **Use case**: Add backup CDNs @@ -344,8 +335,7 @@ Enable MAD4FP content sources. "mad4fpEnabled": false ``` -**Default**: false -**Type**: Boolean +**Default**: false **Type**: Boolean **Use case**: Access MAD4FP community content @@ -359,8 +349,7 @@ MAD4FP content URLs. ] ``` -**Default**: [] -**Type**: Array of URLs +**Default**: [] **Type**: Array of URLs **Use case**: MAD4FP CDN locations @@ -456,8 +445,8 @@ Maximum security (breaks most games). export class ConfigManager { private static settings: ServerSettings | null = null; - static async loadConfig(flashpointPath: string): Promise - static getSettings(): ServerSettings + static async loadConfig(flashpointPath: string): Promise; + static getSettings(): ServerSettings; } ``` @@ -465,7 +454,11 @@ export class ConfigManager { ```typescript // 1. Load from proxySettings.json -const proxySettingsPath = path.join(flashpointPath, 'Server', 'proxySettings.json'); +const proxySettingsPath = path.join( + flashpointPath, + 'Server', + 'proxySettings.json' +); const proxySettings = JSON.parse(await fs.readFile(proxySettingsPath, 'utf-8')); // 2. Derive paths automatically from FLASHPOINT_PATH @@ -619,6 +612,7 @@ ENV GAMEZIPSERVER_PORT=${GAMEZIPSERVER_PORT} **Symptom**: Using default values instead of configured values **Debug steps**: + 1. Check .env file exists in correct location 2. Verify dotenv is loading: `console.log(process.env.PROXY_PORT)` 3. Check for typos in variable names @@ -631,6 +625,7 @@ ENV GAMEZIPSERVER_PORT=${GAMEZIPSERVER_PORT} **Symptom**: `Flashpoint path not found: D:/Flashpoint` **Debug steps**: + 1. Verify path exists: `dir D:\Flashpoint` (Windows) or `ls /flashpoint` (Linux) 2. Check path format (forward slashes on all platforms) 3. Verify permissions (read access required) @@ -643,6 +638,7 @@ ENV GAMEZIPSERVER_PORT=${GAMEZIPSERVER_PORT} **Symptom**: `Port 22500 already in use` **Debug steps**: + 1. Find process: `netstat -ano | findstr :22500` 2. Kill process or change port 3. Check for multiple game-service instances @@ -654,6 +650,7 @@ ENV GAMEZIPSERVER_PORT=${GAMEZIPSERVER_PORT} **Symptom**: Files not found from external sources **Debug steps**: + 1. Test URL manually: `curl https://infinity.flashpointarchive.org/...` 2. Check EXTERNAL_FALLBACK_URLS format 3. Verify network connectivity @@ -691,21 +688,25 @@ LOG_LEVEL=error # Minimal logging overhead ## Security Best Practices 1. **Disable CGI in production**: + ```bash ENABLE_CGI=false ``` 2. **Restrict CORS if internal**: + ```bash ALLOW_CROSS_DOMAIN=false # Internal networks only ``` 3. **Use HTTPS for external URLs**: + ```bash EXTERNAL_FALLBACK_URLS=https://cdn.example.com # HTTPS only ``` 4. **Minimal logging in production**: + ```bash LOG_LEVEL=warn # Don't log sensitive paths ``` @@ -733,6 +734,8 @@ Planned configuration options: ## References - dotenv documentation: https://github.com/motdotla/dotenv -- Node.js environment variables: https://nodejs.org/api/process.html#process_process_env +- Node.js environment variables: + https://nodejs.org/api/process.html#process_process_env - Flashpoint proxySettings.json specification -- Docker environment configuration: https://docs.docker.com/compose/environment-variables/ +- Docker environment configuration: + https://docs.docker.com/compose/environment-variables/ diff --git a/docs/05-game-service/gamezip-server.md b/docs/05-game-service/gamezip-server.md index 21560bd..57a41e8 100644 --- a/docs/05-game-service/gamezip-server.md +++ b/docs/05-game-service/gamezip-server.md @@ -1,10 +1,12 @@ # GameZip Server (Port 22501) -The GameZip Server mounts ZIP archives in-memory and serves files directly using streaming, eliminating disk extraction needs. +The GameZip Server mounts ZIP archives in-memory and serves files directly using +streaming, eliminating disk extraction needs. ## Overview -Mounts ZIP archives and serves files using `node-stream-zip` for streaming without extraction. This saves disk space and improves performance. +Mounts ZIP archives and serves files using `node-stream-zip` for streaming +without extraction. This saves disk space and improves performance. ## Architecture @@ -34,11 +36,13 @@ Content-Type: application/json ``` **Response:** + ```json -{"success": true, "id": "game-123", "zipPath": "..."} +{ "success": true, "id": "game-123", "zipPath": "..." } ``` **Errors:** + - `400`: Missing mount ID or zipPath - `500`: ZIP file not found or mount failed @@ -49,8 +53,9 @@ DELETE /mount/:id HTTP/1.1 ``` **Response:** + ```json -{"success": true, "id": "game-123"} +{ "success": true, "id": "game-123" } ``` ### List Mounted ZIPs @@ -60,6 +65,7 @@ GET /mounts HTTP/1.1 ``` **Response:** + ```json { "mounts": [ @@ -86,6 +92,7 @@ GET /http://www.example.com/path/file.swf HTTP/1.1 ``` **Response:** + ```http HTTP/1.1 200 OK Content-Type: application/x-shockwave-flash @@ -100,6 +107,7 @@ Access-Control-Allow-Origin: * ### URL Format Support GameZip supports three URL formats: + 1. **Proxy-style**: `GET http://domain.com/path` 2. **Path-based**: `GET /http://domain.com/path` 3. **Standard**: `GET /path` with Host header @@ -107,6 +115,7 @@ GameZip supports three URL formats: ### Path Search Strategy For request `www.example.com/path/file.swf`, tries: + 1. `content/www.example.com/path/file.swf` (most common) 2. `htdocs/www.example.com/path/file.swf` (standard) 3. `www.example.com/path/file.swf` (no prefix) @@ -134,6 +143,7 @@ async getFile(id: string, filePath: string): Promise { ``` **Benefits:** + - No disk extraction required - No temporary files created - Direct memory buffer access @@ -146,6 +156,7 @@ Detected from file extensions with 199+ supported types. ## Response Headers ### Standard Headers + ```http Content-Type: application/x-shockwave-flash Content-Length: 123456 @@ -155,6 +166,7 @@ Connection: keep-alive ``` ### CORS Headers + ```http Access-Control-Allow-Origin: * Access-Control-Allow-Methods: GET, POST, OPTIONS @@ -164,12 +176,14 @@ Access-Control-Allow-Headers: * ## HTML Polyfill Injection HTML files are automatically processed to inject compatibility polyfills: + - Unity WebGL support - General browser compatibility shims ## Integration with Backend **Workflow:** + 1. Backend mounts ZIP: `POST /mount/{game.id}` 2. Backend returns launch config with gameZip URLs 3. Frontend loads game from proxy server @@ -179,16 +193,17 @@ HTML files are automatically processed to inject compatibility polyfills: ## Error Handling **Mount Errors:** + - `ZIP file not found` - Invalid zip path - `ZIP already mounted` - Silent success (returns immediately) - `Corrupted ZIP` - File read error **File Access Errors:** + - `File not in ZIP` - Returns 404 - `No ZIPs mounted` - Returns 404 -**Graceful Degradation:** -If GameZip fails, proxy server continues without it. +**Graceful Degradation:** If GameZip fails, proxy server continues without it. ## Testing @@ -214,22 +229,26 @@ curl -X DELETE http://localhost:22501/mount/test-game ## Troubleshooting ### Port Already in Use + ```bash GAMEZIPSERVER_PORT=22502 ``` ### ZIP Mount Fails + 1. Verify path is absolute 2. Check file permissions 3. Ensure ZIP file exists ### File Not Found in ZIP + 1. List mounted ZIPs: `curl http://localhost:22501/mounts` 2. Check if correct ZIP is mounted 3. Verify file exists in ZIP (extract and check) 4. Check ZIP structure (content/, htdocs/, or root?) ### Memory Issues + 1. Limit number of mounted ZIPs 2. Implement auto-unmount for inactive ZIPs 3. Add file size limits @@ -238,20 +257,24 @@ GAMEZIPSERVER_PORT=22502 ## Security ### Path Traversal Prevention + File paths are normalized and validated to prevent directory traversal. ### Mount Access Control + Only explicitly mounted ZIPs are accessible. No arbitrary ZIP path access. ## Performance ### Memory Usage + - Small ZIP (~100 files): ~1MB RAM - Medium ZIP (~1,000 files): ~5MB RAM - Large ZIP (~10,000 files): ~50MB RAM - Each request loads file into memory ### Access Speed + - Index lookup: O(1) hash table - File read: Random access, depends on position - Typical: Small files 2-5ms, medium 10-50ms, large 50-500ms diff --git a/docs/05-game-service/html-polyfills.md b/docs/05-game-service/html-polyfills.md index f3af85c..d23c7df 100644 --- a/docs/05-game-service/html-polyfills.md +++ b/docs/05-game-service/html-polyfills.md @@ -1,10 +1,13 @@ # HTML Polyfills -The game-service automatically injects JavaScript polyfills into HTML files to improve compatibility with Unity WebGL and other game engines. +The game-service automatically injects JavaScript polyfills into HTML files to +improve compatibility with Unity WebGL and other game engines. ## Overview -Many legacy games expect certain browser APIs and global functions to exist. The HTML Polyfill Injector detects game engines and automatically adds compatibility shims to prevent errors and improve stability. +Many legacy games expect certain browser APIs and global functions to exist. The +HTML Polyfill Injector detects game engines and automatically adds compatibility +shims to prevent errors and improve stability. ## Architecture @@ -33,15 +36,15 @@ Injected when Unity game is detected. ```typescript function needsUnityPolyfills(html: string): boolean { const unityIndicators = [ - /UnityProgress/i, // Unity 5.x progress callback - /UnityLoader/i, // Unity loader script - /createUnityInstance/i, // Unity 2020+ API - /unityFramework\.js/i, // Unity framework - /Build\/.*\.loader\.js/i, // Unity build files - /UnityEngine/i // Unity namespace + /UnityProgress/i, // Unity 5.x progress callback + /UnityLoader/i, // Unity loader script + /createUnityInstance/i, // Unity 2020+ API + /unityFramework\.js/i, // Unity framework + /Build\/.*\.loader\.js/i, // Unity build files + /UnityEngine/i, // Unity namespace ]; - return unityIndicators.some(pattern => pattern.test(html)); + return unityIndicators.some((pattern) => pattern.test(html)); } ``` @@ -53,39 +56,45 @@ function needsUnityPolyfills(html: string): boolean { // Unity WebGL Polyfills // 1. UnityProgress - Progress callback for Unity 5.x -window.UnityProgress = window.UnityProgress || function(gameInstance, progress) { - if (!gameInstance.Module) return; - if (!gameInstance.progress) { - gameInstance.progress = { loaded: 0, total: 1 }; - } - gameInstance.progress.loaded = progress; - if (progress === 1) { - console.log('[Unity] Game loaded successfully'); - } -}; +window.UnityProgress = + window.UnityProgress || + function (gameInstance, progress) { + if (!gameInstance.Module) return; + if (!gameInstance.progress) { + gameInstance.progress = { loaded: 0, total: 1 }; + } + gameInstance.progress.loaded = progress; + if (progress === 1) { + console.log('[Unity] Game loaded successfully'); + } + }; // 2. createUnityInstance - Unity 2020+ loader API -window.createUnityInstance = window.createUnityInstance || function(canvas, config) { - return new Promise((resolve) => { - console.log('[Unity] createUnityInstance called - using polyfill'); - resolve({ - Module: {}, - SetFullscreen: function() {}, - SendMessage: function() {}, - Quit: function() { return Promise.resolve(); } +window.createUnityInstance = + window.createUnityInstance || + function (canvas, config) { + return new Promise((resolve) => { + console.log('[Unity] createUnityInstance called - using polyfill'); + resolve({ + Module: {}, + SetFullscreen: function () {}, + SendMessage: function () {}, + Quit: function () { + return Promise.resolve(); + }, + }); }); - }); -}; + }; // 3. UnityLoader2020 - Error handler if (typeof UnityLoader2020 === 'undefined') { window.UnityLoader2020 = { Error: { - handler: function(message, filename, lineno) { + handler: function (message, filename, lineno) { console.warn('[Unity] Error:', message, 'at', filename + ':' + lineno); return true; // Prevent default error handling - } - } + }, + }, }; } ``` @@ -103,7 +112,10 @@ if (typeof window.external === 'undefined') { } // 2. AudioContext - WebKit prefix polyfill -if (typeof AudioContext === 'undefined' && typeof webkitAudioContext !== 'undefined') { +if ( + typeof AudioContext === 'undefined' && + typeof webkitAudioContext !== 'undefined' +) { window.AudioContext = webkitAudioContext; } ``` @@ -151,32 +163,36 @@ const headMatch = htmlString.match(/]*>/i); if (headMatch) { const headOpenTag = headMatch[0]; const insertPosition = htmlString.indexOf(headOpenTag) + headOpenTag.length; - htmlString = htmlString.slice(0, insertPosition) + - polyfillsToInject + - htmlString.slice(insertPosition); -} -else { + htmlString = + htmlString.slice(0, insertPosition) + + polyfillsToInject + + htmlString.slice(insertPosition); +} else { // No tag, try to inject after const htmlMatch = htmlString.match(/]*>/i); if (htmlMatch) { const htmlOpenTag = htmlMatch[0]; const insertPosition = htmlString.indexOf(htmlOpenTag) + htmlOpenTag.length; - htmlString = htmlString.slice(0, insertPosition) + - '' + polyfillsToInject + '' + - htmlString.slice(insertPosition); - } - else { + htmlString = + htmlString.slice(0, insertPosition) + + '' + + polyfillsToInject + + '' + + htmlString.slice(insertPosition); + } else { // No or tag, wrap entire content - htmlString = '' + - polyfillsToInject + - '' + - htmlString + - ''; + htmlString = + '' + + polyfillsToInject + + '' + + htmlString + + ''; } } ``` **Injection priority**: + 1. After `` opening tag (best) 2. Create `` after `` (fallback) 3. Wrap entire content in HTML structure (last resort) @@ -188,20 +204,23 @@ else { **Purpose**: Legacy Unity 5.x games call this to report loading progress **Without polyfill**: + ```javascript UnityProgress(gameInstance, 0.5); // Error: UnityProgress is not defined ``` **With polyfill**: + ```javascript UnityProgress(gameInstance, 0.5); // Works: Updates progress, logs completion ``` **Implementation**: + ```javascript -window.UnityProgress = function(gameInstance, progress) { +window.UnityProgress = function (gameInstance, progress) { if (!gameInstance.Module) return; // Initialize progress tracking @@ -224,33 +243,37 @@ window.UnityProgress = function(gameInstance, progress) { **Purpose**: Unity 2020+ uses this to instantiate games **Without polyfill**: + ```javascript -createUnityInstance(canvas, config).then(instance => { +createUnityInstance(canvas, config).then((instance) => { // Error: createUnityInstance is not defined }); ``` **With polyfill**: + ```javascript -createUnityInstance(canvas, config).then(instance => { +createUnityInstance(canvas, config).then((instance) => { // Works: Returns mock instance }); ``` **Implementation**: + ```javascript -window.createUnityInstance = function(canvas, config) { +window.createUnityInstance = function (canvas, config) { return new Promise((resolve) => { console.log('[Unity] createUnityInstance called - using polyfill'); // Return mock Unity instance resolve({ - Module: {}, // Unity WebAssembly module - SetFullscreen: function() {}, // Fullscreen API - SendMessage: function() {}, // Unity-to-JS messaging - Quit: function() { // Cleanup + Module: {}, // Unity WebAssembly module + SetFullscreen: function () {}, // Fullscreen API + SendMessage: function () {}, // Unity-to-JS messaging + Quit: function () { + // Cleanup return Promise.resolve(); - } + }, }); }); }; @@ -261,25 +284,28 @@ window.createUnityInstance = function(canvas, config) { **Purpose**: Suppress Unity framework errors **Without polyfill**: + ``` Uncaught ReferenceError: UnityLoader2020 is not defined ``` **With polyfill**: + ``` [Unity] Error: ... (logged but not thrown) ``` **Implementation**: + ```javascript if (typeof UnityLoader2020 === 'undefined') { window.UnityLoader2020 = { Error: { - handler: function(message, filename, lineno) { + handler: function (message, filename, lineno) { console.warn('[Unity] Error:', message, 'at', filename + ':' + lineno); return true; // Prevent default error handling - } - } + }, + }, }; } ``` @@ -291,18 +317,21 @@ if (typeof UnityLoader2020 === 'undefined') { **Purpose**: Legacy ActiveX/IE games expect `window.external` **Without polyfill**: + ```javascript window.external.someMethod(); // Error: Cannot read property 'someMethod' of undefined ``` **With polyfill**: + ```javascript window.external.someMethod(); // Error: someMethod is not a function (better than undefined) ``` **Implementation**: + ```javascript if (typeof window.external === 'undefined') { window.external = {}; @@ -314,20 +343,26 @@ if (typeof window.external === 'undefined') { **Purpose**: Older browsers use `webkitAudioContext` **Without polyfill**: + ```javascript const audioContext = new AudioContext(); // Error: AudioContext is not defined (in older Safari/Chrome) ``` **With polyfill**: + ```javascript const audioContext = new AudioContext(); // Works: Uses webkitAudioContext ``` **Implementation**: + ```javascript -if (typeof AudioContext === 'undefined' && typeof webkitAudioContext !== 'undefined') { +if ( + typeof AudioContext === 'undefined' && + typeof webkitAudioContext !== 'undefined' +) { window.AudioContext = webkitAudioContext; } ``` @@ -372,15 +407,15 @@ Create test HTML file: ```html - - Unity Game - - - - - + + Unity Game + + + + + ``` @@ -395,20 +430,21 @@ Expected output (excerpt): ```html - - Unity Game - - - + + Unity Game + + + + ``` ### Test Non-Unity HTML @@ -418,12 +454,12 @@ Create test HTML file: ```html - - Regular Game - - -

Regular content

- + + Regular Game + + +

Regular content

+ ``` @@ -438,13 +474,14 @@ Expected output (excerpt): ```html - - Regular Game - - + + Regular Game + + + ``` ### Test Non-HTML File @@ -458,22 +495,23 @@ Expected: No polyfills injected (binary file returned as-is) ## Browser Compatibility Polyfills tested on: + - Chrome 90+ - Firefox 88+ - Safari 14+ - Edge 90+ **Legacy browser support**: + - IE 11: Limited support (some polyfills may not work) - Chrome < 50: AudioContext polyfill helps - Safari < 10: WebKit prefix polyfills help ## Performance Impact -**HTML parsing**: ~1-5ms for typical game HTML -**Regex matching**: <1ms for detection -**String manipulation**: ~1-5ms for injection -**Total overhead**: ~5-10ms per HTML file +**HTML parsing**: ~1-5ms for typical game HTML **Regex matching**: <1ms for +detection **String manipulation**: ~1-5ms for injection **Total overhead**: +~5-10ms per HTML file **Memory impact**: Negligible (polyfills add ~2KB to HTML) @@ -484,6 +522,7 @@ Polyfills tested on: ### No Unsafe Code Polyfills only add safe, read-only functions: + - No eval() - No innerHTML manipulation - No DOM modifications @@ -492,6 +531,7 @@ Polyfills only add safe, read-only functions: ### Injection Safety HTML is treated as string, not parsed as DOM: + - No XSS risk from polyfill injection - Original content preserved - Only `` tag modified @@ -499,6 +539,7 @@ HTML is treated as string, not parsed as DOM: ### Console Logging Polyfills log to console for debugging: + - No sensitive information logged - Helps developers troubleshoot issues - Can be disabled in production (future) @@ -510,6 +551,7 @@ Polyfills log to console for debugging: **Symptom**: Unity game fails, no polyfills in HTML **Debug steps**: + 1. Check if Content-Type is `text/html` 2. Verify HTML has `` or `` tag 3. Check logs for injection message @@ -522,6 +564,7 @@ Polyfills log to console for debugging: **Symptom**: Polyfills injected but game still fails **Debug steps**: + 1. Open browser console 2. Check for errors before polyfills load 3. Verify polyfill code is executed @@ -534,6 +577,7 @@ Polyfills log to console for debugging: **Symptom**: Unity polyfills injected for non-Unity game **Debug steps**: + 1. Check HTML for Unity indicators 2. Verify detection regex patterns 3. Add exclusion pattern if needed @@ -561,11 +605,8 @@ window.NewEngine = window.NewEngine || { ```typescript function needsNewEnginePolyfills(html: string): boolean { - const indicators = [ - /NewEngine/i, - /newengine\.js/i - ]; - return indicators.some(p => p.test(html)); + const indicators = [/NewEngine/i, /newengine\.js/i]; + return indicators.some((p) => p.test(html)); } ``` @@ -610,7 +651,10 @@ curl http://localhost:22500/test-new.html | grep "NewEngine" ## References -- Unity WebGL API: https://docs.unity3d.com/Manual/webgl-interactingwithbrowserscripting.html +- Unity WebGL API: + https://docs.unity3d.com/Manual/webgl-interactingwithbrowserscripting.html - MDN Polyfills: https://developer.mozilla.org/en-US/docs/Glossary/Polyfill -- WebKit AudioContext: https://developer.mozilla.org/en-US/docs/Web/API/AudioContext -- window.external: https://developer.mozilla.org/en-US/docs/Web/API/Window/external +- WebKit AudioContext: + https://developer.mozilla.org/en-US/docs/Web/API/AudioContext +- window.external: + https://developer.mozilla.org/en-US/docs/Web/API/Window/external diff --git a/docs/05-game-service/legacy-server.md b/docs/05-game-service/legacy-server.md index 28fb495..9481715 100644 --- a/docs/05-game-service/legacy-server.md +++ b/docs/05-game-service/legacy-server.md @@ -1,10 +1,12 @@ # Legacy Server and Fallback Chain -The Legacy Server implements a comprehensive fallback chain for serving legacy web content from multiple sources, ensuring maximum content availability. +The Legacy Server implements a comprehensive fallback chain for serving legacy +web content from multiple sources, ensuring maximum content availability. ## Overview -Multi-level fallback system that checks local directories, external CDNs, and override paths for game files. +Multi-level fallback system that checks local directories, external CDNs, and +override paths for game files. ## Fallback Chain @@ -56,6 +58,7 @@ private buildPathCandidates(relPath: string): Array<{path, type}> { ## Hostname Variations For hostname `mochibot.com`, tries: + 1. `mochibot.com` (original) 2. `www.mochibot.com` 3. `core.mochibot.com` @@ -63,7 +66,8 @@ For hostname `mochibot.com`, tries: 5. `cdn.mochibot.com` 6. ... (12 more standard subdomains) -**Why?** Games may request `mochibot.com` but Flashpoint stores it as `core.mochibot.com`. +**Why?** Games may request `mochibot.com` but Flashpoint stores it as +`core.mochibot.com`. ## Query String Handling @@ -74,6 +78,7 @@ const pathWithoutQuery = relPath.split('?')[0]; ``` **Example:** + - Request: `file.swf?v=123&cache=false` - Try: `file.swf?v=123` (exact match) - Try: `file.swf` (common case) @@ -85,6 +90,7 @@ Files stored without query strings, so both are attempted. Override paths are special directories that take precedence over normal paths. **Configuration** (proxySettings.json): + ```json { "legacyOverridePaths": ["custom", "fixes", "overrides"] @@ -92,11 +98,13 @@ Override paths are special directories that take precedence over normal paths. ``` **Example:** + - Request: `www.example.com/game.swf` - Normal: `D:/Flashpoint/Legacy/htdocs/www.example.com/game.swf` - Override: `D:/Flashpoint/Legacy/htdocs/custom/www.example.com/game.swf` **Use cases:** + - Patched game files - Fixed versions of broken content - Custom modifications @@ -106,7 +114,7 @@ Override paths are special directories that take precedence over normal paths. ### Infinity Server (Primary CDN) ```typescript -infinityServerURL: 'https://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs/' +infinityServerURL: 'https://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs/'; ``` Official Flashpoint CDN for missing local files. @@ -116,8 +124,8 @@ Official Flashpoint CDN for missing local files. ```typescript externalFilePaths: [ 'https://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs', - 'https://infinity.unstable.life/Flashpoint/Legacy/htdocs/' -] + 'https://infinity.unstable.life/Flashpoint/Legacy/htdocs/', +]; ``` Backup CDNs tried in order. @@ -138,6 +146,7 @@ private async tryExternalSource(baseUrl: string, relPath: string) { ``` **Features:** + - HTTPS enforced (HTTP auto-upgraded) - 45 second timeout - 5 redirect limit @@ -155,28 +164,30 @@ if (settings.enableBrotli && filePath.endsWith('.br')) { ``` **Example:** + - Stored: `file.js.br` (Brotli compressed) - Decompressed: `file.js` - MIME: `text/javascript` ## Error Handling -**File Not Found:** -If no source has the file, returns 404 after all sources exhausted. +**File Not Found:** If no source has the file, returns 404 after all sources +exhausted. -**Network Errors:** -External downloads may fail (404, 403, timeout). Service continues to next source without aborting. +**Network Errors:** External downloads may fail (404, 403, timeout). Service +continues to next source without aborting. -**Disk Errors:** -Local file access failures (permissions, not found) continue to next candidate. +**Disk Errors:** Local file access failures (permissions, not found) continue to +next candidate. -**Graceful Degradation:** -Failures don't abort the chain; next source is tried. +**Graceful Degradation:** Failures don't abort the chain; next source is tried. ## Performance Optimization ### Early Exit + Stop as soon as file is found: + ```typescript for (const candidate of pathCandidates) { const stats = await fs.stat(candidate.path); @@ -191,12 +202,14 @@ Typically only 1-3 paths checked before finding file. ## Logging ### Path Resolution + ``` [LegacyServer] Trying: D:/Flashpoint/Legacy/htdocs/www.example.com/game.swf (exact) [LegacyServer] ✓ Found file: D:/Flashpoint/Legacy/htdocs/www.example.com/game.swf ``` ### External Fallback + ``` [LegacyServer] File not found locally, trying external sources... [LegacyServer] Downloading: https://infinity.flashpointarchive.org/.../game.swf @@ -219,7 +232,9 @@ EXTERNAL_FALLBACK_URLS=https://infinity.flashpointarchive.org/...,https://backup { "legacyOverridePaths": ["custom", "fixes"], "infinityServerURL": "https://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs/", - "externalFilePaths": ["https://infinity.unstable.life/Flashpoint/Legacy/htdocs/"], + "externalFilePaths": [ + "https://infinity.unstable.life/Flashpoint/Legacy/htdocs/" + ], "enableBrotli": true, "enableCGI": false } @@ -241,12 +256,15 @@ curl http://localhost:22500/http://test.com/index.html ## Security ### Path Validation + All paths normalized to prevent directory traversal: + - Blocks `../../../etc/passwd` - Blocks absolute path escapes - Blocks URL-encoded traversal ### External CDN + - HTTPS enforced - Timeouts prevent DoS - User-Agent header for tracking diff --git a/docs/05-game-service/mime-types.md b/docs/05-game-service/mime-types.md index 57440de..c8236ca 100644 --- a/docs/05-game-service/mime-types.md +++ b/docs/05-game-service/mime-types.md @@ -1,10 +1,12 @@ # MIME Types -The game-service supports 199+ MIME types for legacy web content, ensuring proper Content-Type headers for all file formats used in Flashpoint games. +The game-service supports 199+ MIME types for legacy web content, ensuring +proper Content-Type headers for all file formats used in Flashpoint games. ## Overview Accurate MIME type detection is critical for: + - Browser rendering (HTML, CSS, JavaScript) - Plugin activation (Flash, Shockwave, Unity) - Media playback (audio, video) @@ -35,11 +37,13 @@ export function getMimeType(extension: string): string { ``` **Priority order**: + 1. Custom (Flashpoint legacy formats) - 199 types 2. Standard (modern web formats) - 33 types 3. Default (unknown types) - application/octet-stream -**Rationale**: Custom types take priority because legacy formats (Flash, Director) may conflict with modern web standards. +**Rationale**: Custom types take priority because legacy formats (Flash, +Director) may conflict with modern web standards. ## Custom MIME Types @@ -102,7 +106,8 @@ tgf: 'chemical/x-mdl-tgf', xyz: 'chemical/x-xyz', // XYZ coordinates ``` -**Use cases**: Chemistry education software, molecular visualization, scientific simulations +**Use cases**: Chemistry education software, molecular visualization, scientific +simulations ### Image Formats (18 types) @@ -227,7 +232,8 @@ xar: 'application/vnd.xara', xpg: 'text/x-xpg', ``` -**Use cases**: Flash games, Java applets, Unity games, Silverlight apps, 3D viewers +**Use cases**: Flash games, Java applets, Unity games, Silverlight apps, 3D +viewers ### Compressed Formats (3 types) @@ -419,6 +425,7 @@ res.end(fileData); ### Browser-Rendered Content Types that browsers render natively: + - `text/html` - HTML pages - `text/css` - Stylesheets - `text/javascript` - Scripts @@ -429,6 +436,7 @@ Types that browsers render natively: ### Plugin-Required Content Types that require browser plugins: + - `application/x-shockwave-flash` - Flash - `application/x-director` - Shockwave - `application/java-archive` - Java @@ -438,6 +446,7 @@ Types that require browser plugins: ### Download-Only Content Types that trigger downloads: + - `application/octet-stream` - Binary files - `application/zip` - Archives - `application/pdf` - Documents (unless viewer installed) @@ -453,20 +462,24 @@ res.setHeader('Content-Type', getMimeType(ext)); res.setHeader('X-Content-Type-Options', 'nosniff'); ``` -**Risk**: Browsers may misinterpret file types if Content-Type is wrong or missing. +**Risk**: Browsers may misinterpret file types if Content-Type is wrong or +missing. ### Script Execution Be cautious with script MIME types: + - `text/javascript` - Executed by browser - `text/html` - May contain inline scripts - `application/x-php` - Never send without execution -**Rule**: Never serve server-side scripts with executable MIME types unless intentional. +**Rule**: Never serve server-side scripts with executable MIME types unless +intentional. ### Default Fallback Unknown file types default to `application/octet-stream`: + - Forces browser to download - Prevents execution - Safe fallback for unknown content @@ -571,6 +584,7 @@ if (CUSTOM_MIME_TYPES[ext]) { ``` **Benchmarks**: + - Lookup time: <0.01ms - Memory overhead: ~50KB for all types - No performance impact on file serving @@ -587,6 +601,7 @@ if (CUSTOM_MIME_TYPES[ext]) { ## References - IANA Media Types: https://www.iana.org/assignments/media-types/ -- MDN MIME Types: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types +- MDN MIME Types: + https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types - Flashpoint proxySettings.json MIME types - Apache MIME types database diff --git a/docs/05-game-service/proxy-server.md b/docs/05-game-service/proxy-server.md index 3ca6898..67faf69 100644 --- a/docs/05-game-service/proxy-server.md +++ b/docs/05-game-service/proxy-server.md @@ -1,10 +1,12 @@ # HTTP Proxy Server (Port 22500) -The HTTP Proxy Server handles all HTTP requests for game content, implementing a multi-level fallback chain to ensure content availability. +The HTTP Proxy Server handles all HTTP requests for game content, implementing a +multi-level fallback chain to ensure content availability. ## Overview -Replicates the functionality of the original FlashpointGameServer, providing legacy web content serving with intelligent fallback mechanisms. +Replicates the functionality of the original FlashpointGameServer, providing +legacy web content serving with intelligent fallback mechanisms. ## Server Configuration @@ -15,9 +17,11 @@ await createHTTPProxyServer({ proxyPort: 22500, legacyHTDOCSPath: 'D:/Flashpoint/Legacy/htdocs', gameDataPath: 'D:/Flashpoint/Data/Games', - externalFilePaths: ['https://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs'], + externalFilePaths: [ + 'https://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs', + ], allowCrossDomain: true, - chunkSize: 8192 + chunkSize: 8192, }); ``` @@ -25,16 +29,17 @@ await createHTTPProxyServer({ ```typescript interface ProxyServerOptions { - proxyPort?: number; // Default: 22500 - legacyHTDOCSPath: string; // Required - gameDataPath?: string; // Optional - externalFilePaths?: string[]; // Optional - allowCrossDomain?: boolean; // Default: true - chunkSize?: number; // Default: 8192 bytes + proxyPort?: number; // Default: 22500 + legacyHTDOCSPath: string; // Required + gameDataPath?: string; // Optional + externalFilePaths?: string[]; // Optional + allowCrossDomain?: boolean; // Default: true + chunkSize?: number; // Default: 8192 bytes } ``` Server timeouts: + - `timeout: 120000` (2 minutes for slow downloads) - `keepAliveTimeout: 65000` (65 seconds keep-alive) @@ -45,18 +50,21 @@ Server timeouts: The proxy supports three URL formats: **1. Proxy-Style Requests** + ```http GET http://www.example.com/path/file.swf HTTP/1.1 Host: localhost:22500 ``` **2. Path-Based Requests** + ```http GET /http://www.example.com/path/file.swf HTTP/1.1 Host: localhost:22500 ``` **3. Standard Requests** + ```http GET /path/file.swf HTTP/1.1 Host: www.example.com @@ -90,6 +98,7 @@ async handleRequest(req: IncomingMessage, res: ServerResponse) { ## Request Routing **Routing Chain:** + 1. Try GameZip Server (if available) - fast path 2. Try Legacy Server (local + external) 3. Send response or error @@ -114,13 +123,15 @@ handleOptionsRequest(req, res) { ### Response Headers All responses include CORS headers when enabled: + ```http Access-Control-Allow-Origin: * Access-Control-Allow-Methods: GET, POST, OPTIONS Access-Control-Allow-Headers: * ``` -**Why CORS required:** Flash games and web content make cross-domain requests for assets and API calls. +**Why CORS required:** Flash games and web content make cross-domain requests +for assets and API calls. ## HTML Polyfill Injection @@ -142,12 +153,14 @@ private sendResponse(res: ServerResponse, data: Buffer, contentType: string) { ``` **Polyfills Injected:** + - Unity WebGL support - General browser compatibility shims ## Response Headers ### Standard Headers + ```http HTTP/1.1 200 OK Content-Type: application/x-shockwave-flash @@ -161,6 +174,7 @@ Access-Control-Allow-Origin: * ### Custom Headers **X-Source** - Indicates where file was served from: + ``` X-Source: local-htdocs # Local htdocs X-Source: gamezipserver:game-123 # GameZip server @@ -169,6 +183,7 @@ X-Source: https://example.com # Secondary source ``` **Cache-Control** - Files cached for 24 hours: + ```http Cache-Control: public, max-age=86400 ``` @@ -176,6 +191,7 @@ Cache-Control: public, max-age=86400 ## Error Handling ### Error Types + 1. **400 Bad Request** - No URL provided 2. **404 Not Found** - File not found in any source 3. **500 Internal Server Error** - Unexpected error @@ -199,7 +215,8 @@ private sendError(res: ServerResponse, statusCode: number, message: string) { } ``` -**Why CORS on errors?** Missing CORS headers from error responses causes browsers to hide the actual error message. +**Why CORS on errors?** Missing CORS headers from error responses causes +browsers to hide the actual error message. ## GameZip Integration @@ -229,6 +246,7 @@ private async tryGameZipServer(hostname: string, urlPath: string) { ``` **Performance:** + - Local request: <5ms - External request: 100-500ms - Speedup: 20-100x faster for ZIP-served content @@ -254,6 +272,7 @@ Reuses TCP connections, reduces handshake overhead. ``` **Log Levels:** + - DEBUG: Detailed path resolution - INFO: Request summary - WARN: Errors, missing files @@ -262,8 +281,10 @@ Reuses TCP connections, reduces handshake overhead. ## Integration with Backend **Request Flow:** + 1. Frontend requests `/api/games/123/launch` -2. Backend returns: `{"launchUrl": "http://localhost:22500/http://www.example.com/game.swf"}` +2. Backend returns: + `{"launchUrl": "http://localhost:22500/http://www.example.com/game.swf"}` 3. Frontend loads game in player iframe 4. Proxy serves content with CORS headers @@ -288,15 +309,17 @@ curl -I "http://localhost:22500/http://www.example.com/test.html" ## Configuration Examples ### Basic + ```typescript await createHTTPProxyServer({ proxyPort: 22500, legacyHTDOCSPath: 'D:/Flashpoint/Legacy/htdocs', - allowCrossDomain: true + allowCrossDomain: true, }); ``` ### Production + ```typescript await createHTTPProxyServer({ proxyPort: parseInt(process.env.PROXY_PORT || '22500'), @@ -304,7 +327,7 @@ await createHTTPProxyServer({ gameDataPath: config.gamesPath, externalFilePaths: (process.env.EXTERNAL_FALLBACK_URLS || '').split(','), allowCrossDomain: process.env.ALLOW_CROSS_DOMAIN !== 'false', - chunkSize: parseInt(process.env.PROXY_CHUNK_SIZE || '8192') + chunkSize: parseInt(process.env.PROXY_CHUNK_SIZE || '8192'), }); ``` @@ -312,16 +335,19 @@ await createHTTPProxyServer({ ### Path Traversal Prevention -All file paths are normalized and validated to prevent directory traversal attacks. +All file paths are normalized and validated to prevent directory traversal +attacks. ### CORS Security CORS enabled by default because: + - Game content requires cross-domain access - Service runs locally, not internet-facing - No sensitive data served For internet-facing deployments, configure specific origins: + ```typescript res.setHeader('Access-Control-Allow-Origin', 'https://trusted-domain.com'); ``` @@ -334,18 +360,21 @@ res.setHeader('Access-Control-Allow-Origin', 'https://trusted-domain.com'); ## Troubleshooting ### Port Already in Use + ```bash netstat -ano | findstr :22500 PROXY_PORT=22510 # Change in .env ``` ### File Not Found + 1. Verify Flashpoint path 2. Check file exists in htdocs 3. Verify external fallback URLs accessible 4. Enable DEBUG logging ### CORS Errors + - Verify `ALLOW_CROSS_DOMAIN=true` - Check response headers: `curl -I ` - Ensure error responses include CORS headers diff --git a/docs/05-game-service/zip-manager.md b/docs/05-game-service/zip-manager.md index 9f6164f..e69bc81 100644 --- a/docs/05-game-service/zip-manager.md +++ b/docs/05-game-service/zip-manager.md @@ -1,10 +1,14 @@ # ZIP Manager -The ZIP Manager provides zero-extraction ZIP archive access using node-stream-zip, enabling efficient game file serving without disk space overhead. +The ZIP Manager provides zero-extraction ZIP archive access using +node-stream-zip, enabling efficient game file serving without disk space +overhead. ## Overview -The ZIP Manager mounts ZIP archives in-memory and provides streaming access to files without extracting them to disk. This approach is ideal for serving game content from compressed archives while conserving disk space. +The ZIP Manager mounts ZIP archives in-memory and provides streaming access to +files without extracting them to disk. This approach is ideal for serving game +content from compressed archives while conserving disk space. ## Architecture @@ -43,10 +47,10 @@ The ZIP Manager mounts ZIP archives in-memory and provides streaming access to f ```typescript interface MountedZip { - id: string; // Unique mount identifier (e.g., "game-123") - zipPath: string; // Absolute path to ZIP file - zip: StreamZip.StreamZipAsync; // node-stream-zip instance - mountTime: Date; // When ZIP was mounted + id: string; // Unique mount identifier (e.g., "game-123") + zipPath: string; // Absolute path to ZIP file + zip: StreamZip.StreamZipAsync; // node-stream-zip instance + mountTime: Date; // When ZIP was mounted } ``` @@ -56,14 +60,16 @@ interface MountedZip { export class ZipManager { private mountedZips: Map = new Map(); - async mount(id: string, zipPath: string): Promise - async unmount(id: string): Promise - async getFile(id: string, filePath: string): Promise - async findFile(relPath: string): Promise<{data: Buffer, mountId: string} | null> - async listFiles(id: string, pattern?: string): Promise - isMounted(id: string): boolean - getMountedZips(): Array<{id, zipPath, mountTime, fileCount}> - async unmountAll(): Promise + async mount(id: string, zipPath: string): Promise; + async unmount(id: string): Promise; + async getFile(id: string, filePath: string): Promise; + async findFile( + relPath: string + ): Promise<{ data: Buffer; mountId: string } | null>; + async listFiles(id: string, pattern?: string): Promise; + isMounted(id: string): boolean; + getMountedZips(): Array<{ id; zipPath; mountTime; fileCount }>; + async unmountAll(): Promise; } ``` @@ -115,8 +121,14 @@ await zipManager.mount('game-123', 'D:/Flashpoint/Data/Games/Flash/G/game.zip'); await zipManager.mount('custom-archive', 'D:/Custom/archive.zip'); // Mount multiple ZIPs -await zipManager.mount('game-456', 'D:/Flashpoint/Data/Games/Flash/A/another.zip'); -await zipManager.mount('game-789', 'D:/Flashpoint/Data/Games/Unity/U/unity-game.zip'); +await zipManager.mount( + 'game-456', + 'D:/Flashpoint/Data/Games/Flash/A/another.zip' +); +await zipManager.mount( + 'game-789', + 'D:/Flashpoint/Data/Games/Unity/U/unity-game.zip' +); ``` ### Duplicate Mount Handling @@ -128,7 +140,8 @@ await zipManager.mount('game-123', 'path/to/game.zip'); await zipManager.mount('game-123', 'path/to/game.zip'); // Silent success, no-op ``` -**Rationale**: Idempotent operations simplify backend logic - no need to check mount status before mounting. +**Rationale**: Idempotent operations simplify backend logic - no need to check +mount status before mounting. ## Unmounting ZIPs @@ -235,7 +248,10 @@ async getFile(id: string, filePath: string): Promise { ```typescript // Get specific file -const data = await zipManager.getFile('game-123', 'content/www.example.com/game.swf'); +const data = await zipManager.getFile( + 'game-123', + 'content/www.example.com/game.swf' +); if (data) { console.log(`Read ${data.length} bytes`); } @@ -285,6 +301,7 @@ async findFile(relPath: string): Promise<{data: Buffer, mountId: string} | null> Different ZIPs may have different internal structures: **Type 1: Content prefix** + ``` game.zip └── content/ @@ -293,6 +310,7 @@ game.zip ``` **Type 2: Htdocs prefix** + ``` game.zip └── htdocs/ @@ -301,6 +319,7 @@ game.zip ``` **Type 3: No prefix** + ``` game.zip └── www.example.com/ @@ -308,6 +327,7 @@ game.zip ``` **Type 4: Full path** + ``` game.zip └── Legacy/ @@ -321,10 +341,12 @@ The findFile() method tries all variations automatically. ### Search Performance **Complexity**: O(m × p) where: + - m = number of mounted ZIPs - p = number of path variations (4) **Typical case**: + - 10 ZIPs mounted - 4 path variations - First match on average: 2nd variation @@ -332,6 +354,7 @@ The findFile() method tries all variations automatically. - Time: <10ms **Worst case**: + - 100 ZIPs mounted - File not found - Total attempts: 400 @@ -379,24 +402,30 @@ const swfFiles = await zipManager.listFiles('game-123', '\\.swf$'); console.log(`SWF files: ${swfFiles.length}`); // List files in specific directory -const contentFiles = await zipManager.listFiles('game-123', '^content/www\\.example\\.com/'); +const contentFiles = await zipManager.listFiles( + 'game-123', + '^content/www\\.example\\.com/' +); console.log(`Content files: ${contentFiles.length}`); ``` ### Performance Considerations **entries() Cost**: + - First call: Loads ZIP index (~10-100ms) - Cached internally by node-stream-zip - Subsequent calls: O(1) hash table access **Memory Usage**: + - Small ZIP (100 files): ~1MB for index - Medium ZIP (1,000 files): ~5MB - Large ZIP (10,000 files): ~50MB - Very large ZIP (100,000 files): ~500MB -**Recommendation**: Don't list files on every request, use sparingly for debugging. +**Recommendation**: Don't list files on every request, use sparingly for +debugging. ## Mount Status @@ -450,6 +479,7 @@ for (const mount of mounts) { ### Streaming (Current Approach) **Process**: + 1. Open ZIP file handle 2. Read central directory index 3. Locate file entry @@ -459,12 +489,14 @@ for (const mount of mounts) { 7. Return Buffer **Advantages**: + - No disk space used for extraction - Fast for small files (<1MB) - No cleanup required - Multiple files accessed independently **Disadvantages**: + - Each access requires decompression - CPU overhead for deflate - Memory spike for large files @@ -473,17 +505,20 @@ for (const mount of mounts) { ### Extraction (Alternative) **Process**: + 1. Extract entire ZIP to disk 2. Read files from extracted directory 3. Cleanup on unmount **Advantages**: + - Fast sequential access - No decompression overhead - Better for large files - OS filesystem cache benefits **Disadvantages**: + - Requires disk space (2x ZIP size) - Extraction time on mount - Cleanup complexity @@ -492,12 +527,14 @@ for (const mount of mounts) { ### When to Use Each **Streaming** (recommended for): + - Many small files - Random access patterns - Limited disk space - Short-lived access **Extraction** (recommended for): + - Few large files - Sequential access patterns - Abundant disk space @@ -508,16 +545,19 @@ for (const mount of mounts) { ### Memory Usage Breakdown **Per mounted ZIP**: + - ZIP handle: ~1KB - File index: ~5KB per 100 files - Example: 1,000 files = ~50KB **Per file access**: + - File buffer: File size in memory - Decompression buffer: ~2x file size temporarily - Example: 1MB file = ~3MB peak memory **Total with 10 ZIPs (10,000 files each), 5 concurrent requests (1MB each)**: + - ZIP indexes: 10 × 500KB = 5MB - File buffers: 5 × 1MB = 5MB - Decompression: 5 × 1MB = 5MB @@ -528,6 +568,7 @@ for (const mount of mounts) { **Current implementation**: No limits **Recommended limits** (future): + ```typescript const MAX_MOUNTED_ZIPS = 100; const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB @@ -550,18 +591,21 @@ console.log(`External: ${(usage.external / 1024 / 1024).toFixed(2)} MB`); ### Mount Errors **ZIP file not found**: + ```typescript await zipManager.mount('game-123', '/invalid/path.zip'); // Throws: Error: ZIP file not found: /invalid/path.zip ``` **ZIP corrupted**: + ```typescript await zipManager.mount('game-123', 'corrupted.zip'); // Throws: Error from node-stream-zip ``` **Permission denied**: + ```typescript await zipManager.mount('game-123', 'no-permission.zip'); // Throws: EACCES: permission denied @@ -570,12 +614,14 @@ await zipManager.mount('game-123', 'no-permission.zip'); ### File Access Errors **File not in ZIP**: + ```typescript const data = await zipManager.getFile('game-123', 'missing.swf'); // Returns: null (not an error) ``` **ZIP not mounted**: + ```typescript const data = await zipManager.getFile('not-mounted', 'file.swf'); // Returns: null (not an error) @@ -584,12 +630,14 @@ const data = await zipManager.getFile('not-mounted', 'file.swf'); ### Unmount Errors **ZIP not mounted**: + ```typescript const success = await zipManager.unmount('not-mounted'); // Returns: false (not an error) ``` **Unmount failed**: + ```typescript const success = await zipManager.unmount('game-123'); // Returns: false, logs error @@ -678,18 +726,21 @@ describe('ZipManager', () => { Typical performance on modern SSD: **Mount time**: + - Small ZIP (<100 files): 10-50ms - Medium ZIP (<1,000 files): 50-200ms - Large ZIP (<10,000 files): 200-1000ms - Very large ZIP (>10,000 files): 1-5 seconds **File access time**: + - Small file (<100KB): 2-5ms - Medium file (<1MB): 5-20ms - Large file (<10MB): 20-100ms - Very large file (>10MB): 100-500ms **Memory usage**: + - Per ZIP: 50KB - 500KB (index) - Per file: File size + decompression overhead diff --git a/docs/06-api-reference/README.md b/docs/06-api-reference/README.md index aa53bf4..7bd4d9b 100644 --- a/docs/06-api-reference/README.md +++ b/docs/06-api-reference/README.md @@ -4,12 +4,13 @@ Complete REST API reference for Flashpoint Web backend service. ## Base URL -Development: `http://localhost:3100/api` -Production: Configure via `DOMAIN` environment variable +Development: `http://localhost:3100/api` Production: Configure via `DOMAIN` +environment variable ## Authentication Most endpoints require JWT token in Authorization header: + ``` Authorization: Bearer ``` @@ -19,6 +20,7 @@ Obtain tokens via `/api/auth/login` or `/api/auth/register`. ## Response Format ### Success Response + ```json { "data": { ... }, @@ -27,6 +29,7 @@ Obtain tokens via `/api/auth/login` or `/api/auth/register`. ``` ### Error Response + ```json { "error": { "code": 400, "message": "Validation error: ..." } @@ -46,11 +49,13 @@ Obtain tokens via `/api/auth/login` or `/api/auth/register`. ## Rate Limiting -Default: 100 requests per 15 minutes. Headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`. +Default: 100 requests per 15 minutes. Headers: `X-RateLimit-Limit`, +`X-RateLimit-Remaining`, `X-RateLimit-Reset`. ## API Endpoints ### Authentication + - [POST /api/auth/login](./authentication-api.md#login) - [POST /api/auth/register](./authentication-api.md#register) - [POST /api/auth/refresh](./authentication-api.md#refresh-token) @@ -58,6 +63,7 @@ Default: 100 requests per 15 minutes. Headers: `X-RateLimit-Limit`, `X-RateLimit - [GET /api/auth/me](./authentication-api.md#get-current-user) ### Games + - [GET /api/games](./games-api.md#search-games) - [GET /api/games/filter-options](./games-api.md#get-filter-options) - [GET /api/games/random](./games-api.md#get-random-game) @@ -66,6 +72,7 @@ Default: 100 requests per 15 minutes. Headers: `X-RateLimit-Limit`, `X-RateLimit - [GET /api/games/:id/related](./games-api.md#get-related-games) ### Users + - [GET /api/users](./users-api.md#list-users) - [GET /api/users/:id](./users-api.md#get-user) - [POST /api/users](./users-api.md#create-user) @@ -76,6 +83,7 @@ Default: 100 requests per 15 minutes. Headers: `X-RateLimit-Limit`, `X-RateLimit - [PATCH /api/users/me/settings](./users-api.md#update-user-settings) ### Roles & Permissions + - [GET /api/roles](./roles-api.md#list-roles) - [GET /api/roles/permissions](./roles-api.md#list-permissions) - [GET /api/roles/:id](./roles-api.md#get-role) @@ -85,6 +93,7 @@ Default: 100 requests per 15 minutes. Headers: `X-RateLimit-Limit`, `X-RateLimit - [DELETE /api/roles/:id](./roles-api.md#delete-role) ### Playlists + - [GET /api/playlists](./playlists-api.md#list-playlists) - [GET /api/playlists/:id](./playlists-api.md#get-playlist) - [POST /api/playlists](./playlists-api.md#create-playlist) @@ -93,6 +102,7 @@ Default: 100 requests per 15 minutes. Headers: `X-RateLimit-Limit`, `X-RateLimit - [DELETE /api/playlists/:id](./playlists-api.md#delete-playlist) ### Favorites + - [GET /api/favorites](./favorites-api.md#get-all-favorites) - [GET /api/favorites/game-ids](./favorites-api.md#get-favorite-game-ids) - [GET /api/favorites/games](./favorites-api.md#get-favorites-with-full-game-data) @@ -105,6 +115,7 @@ Default: 100 requests per 15 minutes. Headers: `X-RateLimit-Limit`, `X-RateLimit - [DELETE /api/favorites](./favorites-api.md#clear-all-favorites) ### Play Tracking + - [POST /api/play/start](./play-tracking-api.md#start-session) - [POST /api/play/end](./play-tracking-api.md#end-session) - [GET /api/play/stats](./play-tracking-api.md#get-user-stats) @@ -115,6 +126,7 @@ Default: 100 requests per 15 minutes. Headers: `X-RateLimit-Limit`, `X-RateLimit - [GET /api/play/games-distribution](./play-tracking-api.md#get-games-distribution) ### Shared & Community Playlists + - [GET /api/playlists/shared/:shareToken](./shared-playlists-api.md#get-shared-playlist-metadata) - [GET /api/playlists/shared/:shareToken/games](./shared-playlists-api.md#get-games-in-shared-playlist) - [POST /api/playlists/shared/:shareToken/clone](./shared-playlists-api.md#clone-shared-playlist-to-user-account) @@ -124,10 +136,12 @@ Default: 100 requests per 15 minutes. Headers: `X-RateLimit-Limit`, `X-RateLimit - [GET /api/games/:id/download/progress](./shared-playlists-api.md#monitor-download-progress-sse) ### Platforms & Tags + - [GET /api/platforms](./platforms-tags-api.md#list-platforms) - [GET /api/tags](./platforms-tags-api.md#list-tags) ### Activities & Audit Logs + - [GET /api/activities](./activities-api.md#list-activity-logs) - [GET /api/activities/stats](./activities-api.md#get-activity-statistics) - [GET /api/activities/trend](./activities-api.md#get-activity-trend) @@ -135,6 +149,7 @@ Default: 100 requests per 15 minutes. Headers: `X-RateLimit-Limit`, `X-RateLimit - [GET /api/activities/breakdown](./activities-api.md#get-activity-breakdown) ### Admin & Operations + - [GET /api/health](./admin-api.md#basic-health-check) - [GET /api/health/detailed](./admin-api.md#detailed-health-check) - [GET /api/cache/stats](./admin-api.md#get-cache-statistics) @@ -147,15 +162,17 @@ Default: 100 requests per 15 minutes. Headers: `X-RateLimit-Limit`, `X-RateLimit - [GET /api/github/stars](./admin-api.md#get-github-star-count) - [GET /api/settings/auth](./admin-api.md#get-auth-settings) - [PATCH /api/settings/auth](./admin-api.md#update-auth-settings) -- [GET /game-files/*](./admin-api.md#get-game-file) +- [GET /game-files/\*](./admin-api.md#get-game-file) ### Domains + - [GET /api/domains](./domains-api.md#list-domains) - [POST /api/domains](./domains-api.md#add-domain) - [DELETE /api/domains/:id](./domains-api.md#delete-domain) - [PATCH /api/domains/:id/default](./domains-api.md#set-default-domain) ### System Settings + - [GET /api/settings](./settings-api.md#get-all-settings) - [GET /api/settings/:category](./settings-api.md#get-category-settings) - [PATCH /api/settings/:category](./settings-api.md#update-category-settings) diff --git a/docs/06-api-reference/activities-api.md b/docs/06-api-reference/activities-api.md index c10f866..79191b3 100644 --- a/docs/06-api-reference/activities-api.md +++ b/docs/06-api-reference/activities-api.md @@ -1,20 +1,26 @@ # Activities API -Audit logs and activity statistics. All endpoints require `activities.read` permission and `enableStatistics` feature flag. +Audit logs and activity statistics. All endpoints require `activities.read` +permission and `enableStatistics` feature flag. ## List Activity Logs `GET /api/activities` - Requires `activities.read` permission -Query params: `page` (default: 1), `limit` (default: 50, max: 100), `userId`, `username`, `action`, `resource`, `startDate`, `endDate`, `sortBy` (createdAt|username|action|resource|ipAddress, default: createdAt), `sortOrder` (asc|desc, default: desc) +Query params: `page` (default: 1), `limit` (default: 50, max: 100), `userId`, +`username`, `action`, `resource`, `startDate`, `endDate`, `sortBy` +(createdAt|username|action|resource|ipAddress, default: createdAt), `sortOrder` +(asc|desc, default: desc) -Returns paginated array with id, userId, username, action, resource, resourceId, details, ipAddress, userAgent, createdAt. +Returns paginated array with id, userId, username, action, resource, resourceId, +details, ipAddress, userAgent, createdAt. ## Get Activity Statistics `GET /api/activities/stats` - Requires `activities.read` permission -Query params: `timeRange` (24h|7d|30d, default: 24h) or custom `startDate`/`endDate` +Query params: `timeRange` (24h|7d|30d, default: 24h) or custom +`startDate`/`endDate` ```json { @@ -38,33 +44,42 @@ Returns array with date, sessions, totalMinutes, uniqueGames for each day. ## Get Top Actions -`GET /api/activities/top-actions?limit=10&timeRange=24h` - Requires `activities.read` permission +`GET /api/activities/top-actions?limit=10&timeRange=24h` - Requires +`activities.read` permission -Query params: `limit` (default: 10, max: 50), `timeRange` (24h|7d|30d, default: 24h) +Query params: `limit` (default: 10, max: 50), `timeRange` (24h|7d|30d, default: +24h) -Returns array with action, count, percentage, category, topResource, exampleActivity. +Returns array with action, count, percentage, category, topResource, +exampleActivity. ## Get Activity Breakdown -`GET /api/activities/breakdown?groupBy=resource&limit=10` - Requires `activities.read` permission +`GET /api/activities/breakdown?groupBy=resource&limit=10` - Requires +`activities.read` permission -Query params: `groupBy` (resource|user|ip, default: resource), `limit` (default: 10, max: 50), `timeRange` (24h|7d|30d, default: 24h) +Query params: `groupBy` (resource|user|ip, default: resource), `limit` (default: +10, max: 50), `timeRange` (24h|7d|30d, default: 24h) -Returns array grouped by specified criteria with key, count, percentage, metadata. +Returns array grouped by specified criteria with key, count, percentage, +metadata. ## Activity Actions Common recorded actions: -**Authentication:** auth.login, auth.login.failed, auth.logout, auth.register, auth.password.change, auth.token.refresh +**Authentication:** auth.login, auth.login.failed, auth.logout, auth.register, +auth.password.change, auth.token.refresh **Games:** games.launch, games.view, games.search -**Playlists:** playlists.create, playlists.view, playlists.update, playlists.delete, playlists.add_game, playlists.remove_game +**Playlists:** playlists.create, playlists.view, playlists.update, +playlists.delete, playlists.add_game, playlists.remove_game **Users:** users.create, users.view, users.update, users.delete, users.settings -**Roles & Permissions:** roles.create, roles.update, roles.delete, permissions.assign, permissions.revoke +**Roles & Permissions:** roles.create, roles.update, roles.delete, +permissions.assign, permissions.revoke ## Best Practices diff --git a/docs/06-api-reference/admin-api.md b/docs/06-api-reference/admin-api.md index 48741a8..5f96bd7 100644 --- a/docs/06-api-reference/admin-api.md +++ b/docs/06-api-reference/admin-api.md @@ -1,6 +1,7 @@ # Admin & Operations API -Administrative endpoints for system monitoring and maintenance. All endpoints require authentication and appropriate permissions unless otherwise noted. +Administrative endpoints for system monitoring and maintenance. All endpoints +require authentication and appropriate permissions unless otherwise noted. ## Health Endpoints @@ -28,14 +29,20 @@ Returns `200 OK` (healthy) or `503 Service Unavailable` (unhealthy) "uptime": 86400, "databases": { "user": { "connected": true, "fileSizeBytes": 2097152 }, - "flashpoint": { "connected": true, "fileSizeBytes": 5368709120, "lastModified": "2026-02-02T12:15:30Z" } + "flashpoint": { + "connected": true, + "fileSizeBytes": 5368709120, + "lastModified": "2026-02-02T12:15:30Z" + } }, "cache": { "gameSearch": { "hits": 45230, "misses": 3421, "hitRate": 0.9296 }, "permissions": { "hits": 12340, "misses": 234, "hitRate": 0.9812 } }, "memory": { "heapUsed": 125829120, "heapTotal": 536870912 }, - "services": { "gameService": { "available": true, "url": "http://localhost:22500" } } + "services": { + "gameService": { "available": true, "url": "http://localhost:22500" } + } } ``` @@ -45,7 +52,8 @@ Returns `200 OK` (healthy) or `503 Service Unavailable` (unhealthy) `GET /api/cache/stats` - Requires `settings.view` permission -Returns cache performance metrics (gameSearch, permissions with size, hits, misses, hitRate). +Returns cache performance metrics (gameSearch, permissions with size, hits, +misses, hitRate). ### Clear Caches @@ -67,7 +75,8 @@ Returns connected status, file size, game count, platform count, tag count. `POST /api/database/reload` - Requires `settings.update` permission -Response: `{ "success": true, "previousGameCount": 62845, "newGameCount": 62847, "duration": 2345 }` +Response: +`{ "success": true, "previousGameCount": 62845, "newGameCount": 62847, "duration": 2345 }` Error: `400 Bad Request` if reload already in progress @@ -77,17 +86,20 @@ Error: `400 Bad Request` if reload already in progress `GET /api/metrics/summary` - Requires `settings.view` permission -Returns uptime, request stats, performance percentiles, cache hit rates, error counts. +Returns uptime, request stats, performance percentiles, cache hit rates, error +counts. ### Get Endpoint Metrics `GET /api/metrics/endpoints` - Requires `settings.view` permission -Returns array of endpoints with method, path, call counts, response times, errors. +Returns array of endpoints with method, path, call counts, response times, +errors. ### Get Slowest Endpoints -`GET /api/metrics/endpoints/slowest?limit=10` - Requires `settings.view` permission +`GET /api/metrics/endpoints/slowest?limit=10` - Requires `settings.view` +permission ### Get Cache Metrics @@ -135,7 +147,8 @@ Error: `409 Conflict` if job already running ### Get Job Logs -`GET /api/jobs/:jobId/logs?limit=50&offset=0` - Requires `settings.update` permission +`GET /api/jobs/:jobId/logs?limit=50&offset=0` - Requires `settings.update` +permission Returns paginated logs with timestamp, status, duration, message, details. @@ -206,13 +219,15 @@ Returns game counts, platform distribution, archive size, user stats. `GET /api/settings/auth` - No auth required -Returns guestAccessEnabled, userRegistrationEnabled, sessionTimeoutMinutes, maxLoginAttempts, lockoutDurationMinutes. +Returns guestAccessEnabled, userRegistrationEnabled, sessionTimeoutMinutes, +maxLoginAttempts, lockoutDurationMinutes. ### Update Auth Settings `PATCH /api/settings/auth` - Requires `settings.update` permission -All fields optional. Validates ranges (sessionTimeoutMinutes: 0-10080, maxLoginAttempts: 1-10). +All fields optional. Validates ranges (sessionTimeoutMinutes: 0-10080, +maxLoginAttempts: 1-10). ## Game Files Endpoint @@ -222,7 +237,8 @@ All fields optional. Validates ranges (sessionTimeoutMinutes: 0-10080, maxLoginA Returns file content with appropriate MIME type and CORS headers. -Uses fallback chain: Game Service → Game Service GameZip → External CDN → Local cache +Uses fallback chain: Game Service → Game Service GameZip → External CDN → Local +cache Query parameter `cache=false` bypasses caching. diff --git a/docs/06-api-reference/authentication-api.md b/docs/06-api-reference/authentication-api.md index a254d9e..743c5ae 100644 --- a/docs/06-api-reference/authentication-api.md +++ b/docs/06-api-reference/authentication-api.md @@ -9,6 +9,7 @@ User authentication, registration, and token management. Body: `{ "username": "string (3-50)", "password": "string (min 6)" }` Returns `200 OK` with user object and tokens: + ```json { "user": { @@ -26,17 +27,20 @@ Returns `200 OK` with user object and tokens: } ``` -Error: `401 Unauthorized` (invalid credentials) or `429 Too Many Requests` (account locked after failed attempts) +Error: `401 Unauthorized` (invalid credentials) or `429 Too Many Requests` +(account locked after failed attempts) ## Register `POST /api/auth/register` - No auth required -Body: `{ "username": "string (3-50)", "email": "valid-email", "password": "string (min 6)" }` +Body: +`{ "username": "string (3-50)", "email": "valid-email", "password": "string (min 6)" }` Returns `201 Created` with user and tokens (same format as login). -Error: `403 Forbidden` (registration disabled) or `409 Conflict` (username/email exists) +Error: `403 Forbidden` (registration disabled) or `409 Conflict` (username/email +exists) ## Refresh Token @@ -74,7 +78,7 @@ Error: `401 Unauthorized` // Login const { data } = await api.post('/auth/login', { username: 'john_doe', - password: 'password123' + password: 'password123', }); localStorage.setItem('accessToken', data.tokens.accessToken); @@ -85,7 +89,7 @@ const response = await api.get('/api/games'); // On 401 - refresh token const refreshed = await api.post('/auth/refresh', { - refreshToken: localStorage.getItem('refreshToken') + refreshToken: localStorage.getItem('refreshToken'), }); localStorage.setItem('accessToken', refreshed.data.accessToken); @@ -93,7 +97,7 @@ localStorage.setItem('refreshToken', refreshed.data.refreshToken); // Logout await api.post('/auth/logout', { - refreshToken: localStorage.getItem('refreshToken') + refreshToken: localStorage.getItem('refreshToken'), }); localStorage.removeItem('accessToken'); @@ -136,8 +140,8 @@ Common permissions returned in login/me: ```javascript // Axios interceptor for automatic token refresh api.interceptors.response.use( - response => response, - async error => { + (response) => response, + async (error) => { if (error.response?.status === 401) { const refreshToken = localStorage.getItem('refreshToken'); const { data } = await axios.post('/api/auth/refresh', { refreshToken }); diff --git a/docs/06-api-reference/domains-api.md b/docs/06-api-reference/domains-api.md index df73bc3..d8675ec 100644 --- a/docs/06-api-reference/domains-api.md +++ b/docs/06-api-reference/domains-api.md @@ -2,8 +2,8 @@ Manage custom domains for playlist sharing URLs and CORS configuration. -**Authentication:** Required (JWT Bearer token) -**Permissions:** `settings.read` (list), `settings.update` (add, delete, set default) +**Authentication:** Required (JWT Bearer token) **Permissions:** `settings.read` +(list), `settings.update` (add, delete, set default) ## List Domains @@ -12,6 +12,7 @@ Manage custom domains for playlist sharing URLs and CORS configuration. Returns all configured domains, ordered by default status then hostname. **Response:** + ```json [ { @@ -36,6 +37,7 @@ Returns all configured domains, ordered by default status then hostname. Adds a new domain for CORS and share link configuration. **Request Body:** + ```json { "hostname": "play.example.com" @@ -43,6 +45,7 @@ Adds a new domain for CORS and share link configuration. ``` **Validation Rules:** + - No protocol prefix (`http://` or `https://` rejected) - No path, query string, or fragment (`/`, `?`, `#` rejected) - Ports allowed (e.g., `localhost:5173`) @@ -50,6 +53,7 @@ Adds a new domain for CORS and share link configuration. - Case-insensitive uniqueness (stored lowercase) Returns `201 Created`: + ```json { "id": 3, @@ -60,7 +64,9 @@ Returns `201 Created`: ``` Errors: -- `400 Bad Request` - Invalid hostname format, duplicate hostname, or empty hostname + +- `400 Bad Request` - Invalid hostname format, duplicate hostname, or empty + hostname - `401 Unauthorized` - Authentication required - `403 Forbidden` - Insufficient permissions @@ -68,9 +74,11 @@ Errors: `DELETE /api/domains/:id` - Requires `settings.update` permission -Removes a domain. If the deleted domain was the default, no domain is default until one is explicitly set. +Removes a domain. If the deleted domain was the default, no domain is default +until one is explicitly set. Returns `200 OK`: + ```json { "success": true @@ -78,20 +86,24 @@ Returns `200 OK`: ``` Errors: + - `404 Not Found` - Domain not found ## Set Default Domain `PATCH /api/domains/:id/default` - Requires `settings.update` permission -Sets the specified domain as the default. Clears the previous default in a transaction. +Sets the specified domain as the default. Clears the previous default in a +transaction. The default domain is: + - Used in share links for non-admin users - Exposed via `GET /api/settings/public` as `domains.defaultDomain` - Returned to regular users through the `usePublicSettings()` hook Returns `200 OK`: + ```json { "id": 1, @@ -102,13 +114,17 @@ Returns `200 OK`: ``` Errors: + - `404 Not Found` - Domain not found ## CORS Integration -All configured domains are automatically added to the backend's dynamic CORS whitelist. Both `http://` and `https://` variants are generated for each hostname. +All configured domains are automatically added to the backend's dynamic CORS +whitelist. Both `http://` and `https://` variants are generated for each +hostname. -The CORS cache refreshes every 60 seconds and is immediately invalidated when domains are added, deleted, or modified. +The CORS cache refreshes every 60 seconds and is immediately invalidated when +domains are added, deleted, or modified. ## Common Workflows @@ -117,7 +133,7 @@ The CORS cache refreshes every 60 seconds and is immediately invalidated when do ```javascript // 1. Add domain const domain = await api.post('/domains', { - hostname: 'play.example.com' + hostname: 'play.example.com', }); // 2. Make it default (used in share links) @@ -138,7 +154,7 @@ const domains = await api.get('/domains'); await api.delete(`/domains/${oldDomainId}`); // Set new default -const newDefault = domains.find(d => d.hostname === 'new.example.com'); +const newDefault = domains.find((d) => d.hostname === 'new.example.com'); await api.patch(`/domains/${newDefault.id}/default`); ``` @@ -163,7 +179,12 @@ const updated = await domainsApi.setDefault(1); **React Query Hooks:** ```typescript -import { useDomains, useAddDomain, useDeleteDomain, useSetDefaultDomain } from '@/hooks/useDomains'; +import { + useDomains, + useAddDomain, + useDeleteDomain, + useSetDefaultDomain, +} from '@/hooks/useDomains'; const { data: domains, isLoading } = useDomains(); const addMutation = useAddDomain(); @@ -174,14 +195,14 @@ const setDefaultMutation = useSetDefaultDomain(); addMutation.mutate('play.example.com', { onSuccess: (newDomain) => { console.log('Domain added:', newDomain.hostname); - } + }, }); // Delete domain deleteMutation.mutate(1, { onSuccess: () => { console.log('Domain deleted'); - } + }, }); // Set default (supports optimistic updates) @@ -199,6 +220,9 @@ setDefaultMutation.mutate(1); ## Related -- [Settings API](./settings-api.md) - Public settings include `domains.defaultDomain` -- [System Settings Feature](../10-features/system-settings.md) - Domain Settings UI tab -- [Database Schema](../12-reference/database-schema-reference.md) - `domains` table schema +- [Settings API](./settings-api.md) - Public settings include + `domains.defaultDomain` +- [System Settings Feature](../10-features/system-settings.md) - Domain Settings + UI tab +- [Database Schema](../12-reference/database-schema-reference.md) - `domains` + table schema diff --git a/docs/06-api-reference/favorites-api.md b/docs/06-api-reference/favorites-api.md index 74b5cb4..43e7f98 100644 --- a/docs/06-api-reference/favorites-api.md +++ b/docs/06-api-reference/favorites-api.md @@ -22,7 +22,8 @@ Returns `{ "gameIds": ["uuid1", "uuid2", ...] }` for quick checks. `GET /api/favorites/games` - Requires `playlists.read` permission -Query params: `limit`, `offset`, `sortBy` (title|dateAdded, default: dateAdded), `sortOrder` (asc|desc, default: desc) +Query params: `limit`, `offset`, `sortBy` (title|dateAdded, default: dateAdded), +`sortOrder` (asc|desc, default: desc) Returns array of game objects with full metadata including addedAt timestamp. @@ -85,23 +86,26 @@ Returns `{ "removed": 47 }` ## Usage Examples ### Check if game is favorited + ```javascript const { gameIds } = await api.get('/favorites/game-ids'); const isFavorited = gameIds.includes(gameId); ``` ### Batch operations (efficient) + ```javascript // Adding 5 games = 1 rate limit unit -await api.post('/favorites/batch', { gameIds: [1,2,3,4,5] }); +await api.post('/favorites/batch', { gameIds: [1, 2, 3, 4, 5] }); // Adding 5 individually = 5 rate limit units (inefficient) -for (const id of [1,2,3,4,5]) { +for (const id of [1, 2, 3, 4, 5]) { await api.post('/favorites', { gameId: id }); } ``` ### Create playlist from favorites + ```javascript const { gameIds } = await api.get('/favorites/game-ids'); const playlist = await api.post('/playlists', { title: 'My Favorites' }); @@ -117,19 +121,20 @@ await api.post(`/playlists/${playlist.id}/games`, { gameIds }); ## Permission Matrix -| Endpoint | Method | Required Permission | -|----------|--------|-------------------| -| `/api/favorites` | GET | `playlists.read` | -| `/api/favorites/game-ids` | GET | `playlists.read` | -| `/api/favorites/games` | GET | `playlists.read` | -| `/api/favorites/stats` | GET | `playlists.read` | -| `/api/favorites/toggle` | POST | `playlists.create` | -| `/api/favorites` | POST | `playlists.create` | -| `/api/favorites/:gameId` | DELETE | `playlists.delete` | -| `/api/favorites/batch` | POST | `playlists.create` | -| `/api/favorites/batch` | DELETE | `playlists.delete` | -| `/api/favorites` | DELETE | `playlists.delete` | +| Endpoint | Method | Required Permission | +| ------------------------- | ------ | ------------------- | +| `/api/favorites` | GET | `playlists.read` | +| `/api/favorites/game-ids` | GET | `playlists.read` | +| `/api/favorites/games` | GET | `playlists.read` | +| `/api/favorites/stats` | GET | `playlists.read` | +| `/api/favorites/toggle` | POST | `playlists.create` | +| `/api/favorites` | POST | `playlists.create` | +| `/api/favorites/:gameId` | DELETE | `playlists.delete` | +| `/api/favorites/batch` | POST | `playlists.create` | +| `/api/favorites/batch` | DELETE | `playlists.delete` | +| `/api/favorites` | DELETE | `playlists.delete` | ## Rate Limiting -Subject to global rate limit (default: 100 requests per 15 minutes). Batch operations count as single request for efficiency. +Subject to global rate limit (default: 100 requests per 15 minutes). Batch +operations count as single request for efficiency. diff --git a/docs/06-api-reference/games-api.md b/docs/06-api-reference/games-api.md index ef6c849..bd4e131 100644 --- a/docs/06-api-reference/games-api.md +++ b/docs/06-api-reference/games-api.md @@ -6,15 +6,22 @@ Search, browse, and retrieve game information. `GET /api/games` - No auth required -Query params: `search` (title/developer/publisher), `platform`, `series`, `developers`, `publishers`, `playModes`, `languages`, `library` (arcade|theatre), `tags`, `yearFrom`, `yearTo`, `sortBy` (title|releaseDate|dateAdded|developer, default: title), `sortOrder` (asc|desc, default: asc), `page` (default: 1), `limit` (default: 50, max: 100), `showBroken` (default: false), `showExtreme` (default: false) +Query params: `search` (title/developer/publisher), `platform`, `series`, +`developers`, `publishers`, `playModes`, `languages`, `library` +(arcade|theatre), `tags`, `yearFrom`, `yearTo`, `sortBy` +(title|releaseDate|dateAdded|developer, default: title), `sortOrder` (asc|desc, +default: asc), `page` (default: 1), `limit` (default: 50, max: 100), +`showBroken` (default: false), `showExtreme` (default: false) -Returns paginated array with standard game fields (id, title, developer, publisher, platformName, releaseDate, tags, description, etc.). +Returns paginated array with standard game fields (id, title, developer, +publisher, platformName, releaseDate, tags, description, etc.). ## Get Filter Options `GET /api/games/filter-options` - No auth required -Returns platforms, developers, publishers, series, playModes, languages arrays with name/code and count. +Returns platforms, developers, publishers, series, playModes, languages arrays +with name/code and count. ## Get Random Game @@ -30,7 +37,11 @@ Error: `404 Not Found` if no games match criteria `GET /api/games/:id` - No auth required -Returns full game object with all fields: id, title, alternateTitles, developer, publisher, platformName, releaseDate, version, series, playMode, status, notes, language, library, tags, description, launchCommand, applicationPath, source, originalDescription, presentOnDisk, activeDataOnDisk, activeDataId, broken, extreme, playCount, archiveState. +Returns full game object with all fields: id, title, alternateTitles, developer, +publisher, platformName, releaseDate, version, series, playMode, status, notes, +language, library, tags, description, launchCommand, applicationPath, source, +originalDescription, presentOnDisk, activeDataOnDisk, activeDataId, broken, +extreme, playCount, archiveState. Error: `404 Not Found` @@ -38,7 +49,8 @@ Error: `404 Not Found` `GET /api/games/:id/launch` - No auth required -Returns gameId, title, platform, launchCommand, contentUrl (proxied through game-service), applicationPath, playMode, canPlayInBrowser. +Returns gameId, title, platform, launchCommand, contentUrl (proxied through +game-service), applicationPath, playMode, canPlayInBrowser. ## Get Related Games @@ -60,39 +72,39 @@ await api.get('/games', { yearTo: 2010, sortBy: 'releaseDate', sortOrder: 'desc', - limit: 50 - } + limit: 50, + }, }); // Multiple platforms await api.get('/games', { - params: { platform: 'Flash,HTML5,Unity' } + params: { platform: 'Flash,HTML5,Unity' }, }); // Search by text await api.get('/games', { - params: { search: 'platformer', limit: 20 } + params: { search: 'platformer', limit: 20 }, }); ``` ## Game Data Fields -| Field | Type | Description | -|-------|------|-------------| -| id | string (uuid) | Unique game identifier | -| title | string | Game name | -| platformName | string | Platform (Flash, HTML5, etc.) | -| library | string | "arcade" (games) or "theatre" (animations) | -| developer | string | Developer name | -| publisher | string | Publisher name | -| releaseDate | string (YYYY-MM-DD) | Original release date | -| tags | string | Semicolon-separated tags | -| description | string | Full description | -| launchCommand | string | File/URL to launch | -| broken | boolean | Known to be broken | -| extreme | boolean | Contains extreme content | -| presentOnDisk | boolean | Files available locally | -| canPlayInBrowser | boolean | Playable via web (Flash/HTML5) | +| Field | Type | Description | +| ---------------- | ------------------- | ------------------------------------------ | +| id | string (uuid) | Unique game identifier | +| title | string | Game name | +| platformName | string | Platform (Flash, HTML5, etc.) | +| library | string | "arcade" (games) or "theatre" (animations) | +| developer | string | Developer name | +| publisher | string | Publisher name | +| releaseDate | string (YYYY-MM-DD) | Original release date | +| tags | string | Semicolon-separated tags | +| description | string | Full description | +| launchCommand | string | File/URL to launch | +| broken | boolean | Known to be broken | +| extreme | boolean | Contains extreme content | +| presentOnDisk | boolean | Files available locally | +| canPlayInBrowser | boolean | Playable via web (Flash/HTML5) | ## Best Practices diff --git a/docs/06-api-reference/platforms-tags-api.md b/docs/06-api-reference/platforms-tags-api.md index 1289eba..9af8e99 100644 --- a/docs/06-api-reference/platforms-tags-api.md +++ b/docs/06-api-reference/platforms-tags-api.md @@ -28,7 +28,7 @@ Returns array with name, count. Sorted by count (highest first). ```javascript const games = await api.get('/games', { - params: { platform: 'Flash' } + params: { platform: 'Flash' }, }); ``` @@ -36,7 +36,7 @@ const games = await api.get('/games', { ```javascript const games = await api.get('/games', { - params: { platform: 'Flash,HTML5,Unity' } + params: { platform: 'Flash,HTML5,Unity' }, }); ``` @@ -58,8 +58,8 @@ const games = await api.get('/games', { platform: 'Flash', tags: 'Platformer', sortBy: 'releaseDate', - sortOrder: 'desc' - } + sortOrder: 'desc', + }, }); ``` @@ -70,9 +70,9 @@ const games = await api.get('/games', { ```javascript const { data: platforms } = await api.get('/platforms'); -const options = platforms.map(p => ({ +const options = platforms.map((p) => ({ label: `${p.platform} (${p.count.toLocaleString()})`, - value: p.platform + value: p.platform, })); ``` @@ -88,7 +88,7 @@ class TagAutocomplete { filter(query) { const matches = this.tags - .filter(tag => tag.name.toLowerCase().includes(query.toLowerCase())) + .filter((tag) => tag.name.toLowerCase().includes(query.toLowerCase())) .slice(0, 10); this.showDropdown(matches); @@ -100,11 +100,14 @@ class TagAutocomplete { Common tags organized by category: -**Genres:** Action, Adventure, Platformer, Puzzle, RPG, Strategy, Shooter, Sports, Racing, Fighting +**Genres:** Action, Adventure, Platformer, Puzzle, RPG, Strategy, Shooter, +Sports, Racing, Fighting -**Themes:** Fantasy, Sci-Fi, Horror, Mystery, Historical, Educational, Kids, Retro +**Themes:** Fantasy, Sci-Fi, Horror, Mystery, Historical, Educational, Kids, +Retro -**Gameplay:** Multiplayer, Single Player, Turn-Based, Real-Time, Point-and-Click, Simulation +**Gameplay:** Multiplayer, Single Player, Turn-Based, Real-Time, +Point-and-Click, Simulation **Style:** Pixel Art, 3D, Cartoon, Realistic, Abstract, Anime @@ -114,7 +117,8 @@ Platforms and tags change infrequently - ideal for caching: ```javascript class FilterCache { - constructor(ttl = 3600000) { // 1 hour + constructor(ttl = 3600000) { + // 1 hour this.ttl = ttl; this.cache = new Map(); } diff --git a/docs/06-api-reference/play-tracking-api.md b/docs/06-api-reference/play-tracking-api.md index 07a0981..3cd5f00 100644 --- a/docs/06-api-reference/play-tracking-api.md +++ b/docs/06-api-reference/play-tracking-api.md @@ -22,25 +22,30 @@ Error: `404 Not Found` if session doesn't exist `GET /api/play/stats` - Requires `games.play` permission -Returns totalSessions, totalPlayTimeMinutes, totalGamesPlayed, averageSessionMinutes, completedSessions, abandonedSessions, firstPlayedAt, lastPlayedAt. +Returns totalSessions, totalPlayTimeMinutes, totalGamesPlayed, +averageSessionMinutes, completedSessions, abandonedSessions, firstPlayedAt, +lastPlayedAt. ## Get Game Stats `GET /api/play/game-stats?limit=50&offset=0` - Requires `games.play` permission -Returns paginated array with gameId, gameTitle, totalSessions, totalPlayTimeMinutes, lastPlayedAt, firstPlayedAt, averageSessionMinutes. +Returns paginated array with gameId, gameTitle, totalSessions, +totalPlayTimeMinutes, lastPlayedAt, firstPlayedAt, averageSessionMinutes. ## Get Play History `GET /api/play/history?limit=50&offset=0` - Requires `games.play` permission -Returns paginated array with sessionId, gameId, gameTitle, startedAt, endedAt, durationMinutes, completed. +Returns paginated array with sessionId, gameId, gameTitle, startedAt, endedAt, +durationMinutes, completed. ## Get Top Games `GET /api/play/top-games?limit=10` - Requires `games.play` permission -Returns array sorted by total play time (descending) with gameId, gameTitle, totalSessions, totalPlayTimeMinutes, lastPlayedAt. +Returns array sorted by total play time (descending) with gameId, gameTitle, +totalSessions, totalPlayTimeMinutes, lastPlayedAt. ## Get Activity Over Time @@ -48,20 +53,23 @@ Returns array sorted by total play time (descending) with gameId, gameTitle, tot Query param: `days` (default: 30, max: 365) -Returns array with date, sessions, totalMinutes, uniqueGames for each day (includes days with 0 activity). +Returns array with date, sessions, totalMinutes, uniqueGames for each day +(includes days with 0 activity). ## Get Games Distribution `GET /api/play/games-distribution?limit=10` - Requires `games.play` permission -Returns pie chart data with gameTitle, playTimeMinutes, percentage. Games beyond limit grouped as "Others". +Returns pie chart data with gameTitle, playTimeMinutes, percentage. Games beyond +limit grouped as "Others". ## Session Management Pattern ```javascript class PlaySessionManager { async start(gameId, gameTitle, token) { - const { data } = await axios.post('/api/play/start', + const { data } = await axios.post( + '/api/play/start', { gameId, gameTitle }, { headers: { Authorization: `Bearer ${token}` } } ); @@ -72,7 +80,8 @@ class PlaySessionManager { async end() { if (!this.sessionId) return; try { - await axios.post('/api/play/end', + await axios.post( + '/api/play/end', { sessionId: this.sessionId }, { headers: { Authorization: `Bearer ${token}` } } ); @@ -91,17 +100,25 @@ class PlaySessionManager { ```javascript async function loadStatsDashboard(token) { const [stats, topGames, activity, distribution] = await Promise.all([ - axios.get('/api/play/stats', { headers: { Authorization: `Bearer ${token}` } }), - axios.get('/api/play/top-games?limit=5', { headers: { Authorization: `Bearer ${token}` } }), - axios.get('/api/play/activity-over-time?days=30', { headers: { Authorization: `Bearer ${token}` } }), - axios.get('/api/play/games-distribution?limit=10', { headers: { Authorization: `Bearer ${token}` } }) + axios.get('/api/play/stats', { + headers: { Authorization: `Bearer ${token}` }, + }), + axios.get('/api/play/top-games?limit=5', { + headers: { Authorization: `Bearer ${token}` }, + }), + axios.get('/api/play/activity-over-time?days=30', { + headers: { Authorization: `Bearer ${token}` }, + }), + axios.get('/api/play/games-distribution?limit=10', { + headers: { Authorization: `Bearer ${token}` }, + }), ]); return { overview: stats.data, topGames: topGames.data, activityChart: activity.data, - distributionChart: distribution.data + distributionChart: distribution.data, }; } ``` @@ -117,7 +134,8 @@ async function loadStatsDashboard(token) { ## Notes -- Sessions have three states: Active, Completed, Abandoned (auto-cleaned after 24h) +- Sessions have three states: Active, Completed, Abandoned (auto-cleaned after + 24h) - Cleanup job runs every 6 hours - Play time rounded to minutes - Backend batches session writes for efficiency diff --git a/docs/06-api-reference/playlists-api.md b/docs/06-api-reference/playlists-api.md index 4221adb..6449fb8 100644 --- a/docs/06-api-reference/playlists-api.md +++ b/docs/06-api-reference/playlists-api.md @@ -6,13 +6,15 @@ Manage curated game collections. `GET /api/playlists` - No auth required -Returns array with id, title, description, author, icon, library, extreme, gameCount. +Returns array with id, title, description, author, icon, library, extreme, +gameCount. ## Get Playlist `GET /api/playlists/:id` - No auth required -Returns full playlist with games array (id, title, developer, platformName, releaseDate, tags, description). +Returns full playlist with games array (id, title, developer, platformName, +releaseDate, tags, description). Error: `404 Not Found` @@ -20,7 +22,8 @@ Error: `404 Not Found` `POST /api/playlists` - Optional auth -Body: `{ "title": "string (required)", "description": "string (optional)", "author": "string (optional)", "icon": "string (optional)", "library": "arcade"|"theatre" (optional), "extreme": boolean (optional) }` +Body: +`{ "title": "string (required)", "description": "string (optional)", "author": "string (optional)", "icon": "string (optional)", "library": "arcade"|"theatre" (optional), "extreme": boolean (optional) }` Returns `201 Created` with new playlist (empty games array). @@ -58,16 +61,16 @@ const playlist = await api.post('/playlists', { title: 'My Favorite Platformers', description: 'Collection of favorites', author: 'john_doe', - library: 'arcade' + library: 'arcade', }); // 2. Search for games const results = await api.get('/games', { - params: { tags: 'Platformer', platform: 'Flash', limit: 20 } + params: { tags: 'Platformer', platform: 'Flash', limit: 20 }, }); // 3. Add games -const gameIds = results.games.slice(0, 10).map(g => g.id); +const gameIds = results.games.slice(0, 10).map((g) => g.id); await api.post(`/playlists/${playlist.id}/games`, { gameIds }); ``` @@ -81,7 +84,7 @@ const exported = { description: playlist.description, author: playlist.author, library: playlist.library, - gameIds: playlist.games.map(g => g.id) + gameIds: playlist.games.map((g) => g.id), }; // Save to file @@ -101,7 +104,7 @@ const playlist = await api.post('/playlists', { title: `${username}'s Favorites`, description: 'Personal favorite games', author: username, - library: 'arcade' + library: 'arcade', }); await api.post(`/playlists/${playlist.id}/games`, { gameIds }); ``` diff --git a/docs/06-api-reference/roles-api.md b/docs/06-api-reference/roles-api.md index 7496f49..fcf4f80 100644 --- a/docs/06-api-reference/roles-api.md +++ b/docs/06-api-reference/roles-api.md @@ -6,7 +6,8 @@ Manage role-based access control (RBAC). `GET /api/roles` - Requires `roles.read` permission -Returns array of roles with id, name, description, priority, isSystem, createdAt, permissions array. +Returns array of roles with id, name, description, priority, isSystem, +createdAt, permissions array. ## List Permissions @@ -28,7 +29,8 @@ Error: `404 Not Found` `POST /api/roles` - Requires `roles.create` permission -Body: `{ "name": "string (3-50 chars)", "description": "optional", "priority": integer, "permissionIds": [1, 2, ...] }` +Body: +`{ "name": "string (3-50 chars)", "description": "optional", "priority": integer, "permissionIds": [1, 2, ...] }` Returns `201 Created` with new role. @@ -38,7 +40,8 @@ Error: `409 Conflict` if name already exists `PATCH /api/roles/:id` - Requires `roles.update` permission -Body (all optional): `{ "name": "string", "description": "string", "priority": integer }` +Body (all optional): +`{ "name": "string", "description": "string", "priority": integer }` Cannot modify system roles (admin, user, moderator, guest). @@ -62,18 +65,19 @@ Returns `{ "success": true }` ## System Roles -| Role | ID | Priority | Description | -|------|----|---------:|-------------| -| admin | 1 | 100 | Full system access | -| user | 2 | 10 | Standard user | -| moderator | 3 | 50 | Limited admin | -| guest | 4 | 1 | Read-only | +| Role | ID | Priority | Description | +| --------- | --- | -------: | ------------------ | +| admin | 1 | 100 | Full system access | +| user | 2 | 10 | Standard user | +| moderator | 3 | 50 | Limited admin | +| guest | 4 | 1 | Read-only | ## Permission Categories **Games:** games.play, games.read, games.download -**Playlists:** playlists.create, playlists.update, playlists.delete, playlists.manage +**Playlists:** playlists.create, playlists.update, playlists.delete, +playlists.manage **Users:** users.read, users.create, users.update, users.delete @@ -94,14 +98,17 @@ Returns `{ "success": true }` ```javascript const permissions = await api.get('/roles/permissions'); const contentPerms = permissions - .filter(p => p.category === 'games' || - (p.category === 'playlists' && p.name !== 'playlists.delete')) - .map(p => p.id); + .filter( + (p) => + p.category === 'games' || + (p.category === 'playlists' && p.name !== 'playlists.delete') + ) + .map((p) => p.id); await api.post('/roles', { name: 'content_manager', description: 'Manages game content and playlists', priority: 40, - permissionIds: contentPerms + permissionIds: contentPerms, }); ``` diff --git a/docs/06-api-reference/settings-api.md b/docs/06-api-reference/settings-api.md index e4226cf..7db3dc1 100644 --- a/docs/06-api-reference/settings-api.md +++ b/docs/06-api-reference/settings-api.md @@ -2,8 +2,8 @@ Manage system-wide configuration settings. -**Authentication:** Required (JWT Bearer token) -**Permissions:** `settings.read` (view), `settings.update` (modify) +**Authentication:** Required (JWT Bearer token) **Permissions:** `settings.read` +(view), `settings.update` (modify) ## Get All Settings @@ -49,7 +49,8 @@ Returns keyCount, categoryCount, hitRate, size. `POST /api/settings/_cache/clear` - Admin only -Body (optional): `{ "category": "auth" }` (clear specific category or all if not provided) +Body (optional): `{ "category": "auth" }` (clear specific category or all if not +provided) ### Get Permission Cache Stats @@ -60,40 +61,43 @@ Body (optional): `{ "category": "auth" }` (clear specific category or all if not `POST /_cache/permissions/clear` - Admin only Body options: + - `{ "type": "user", "id": 5 }` - Clear user 5 - `{ "type": "role", "id": 2 }` - Clear role 2 - `{ "type": "all" }` or `{}` - Clear all ## Settings Categories -| Category | Setting | Type | Default | Validation | -|----------|---------|------|---------|-----------| -| auth | guestAccessEnabled | boolean | true | - | -| auth | userRegistrationEnabled | boolean | true | - | -| auth | maxLoginAttempts | number | 5 | 1-20 | -| auth | lockoutDurationMinutes | number | 15 | 1-1440 | -| app | siteName | string | Flashpoint Web | maxLength: 100 | -| app | maintenanceMode | boolean | false | - | -| app | defaultTheme | string | dark | light\|dark\|system | -| app | defaultPrimaryColor | string | blue | blue\|green\|red\|purple\|orange\|pink | -| metadata | autoSyncEnabled | boolean | false | - | -| metadata | syncIntervalMinutes | number | 60 | 0-1440 | -| features | enablePlaylists | boolean | true | - | -| features | enableFavorites | boolean | true | - | -| features | enableStatistics | boolean | true | - | -| features | enableActivityLog | boolean | true | - | -| game | defaultScaleMode | string | showall | showall\|exactfit\|noborder\|noscale | -| game | defaultVolume | number | 0.7 | 0-1 | +| Category | Setting | Type | Default | Validation | +| -------- | ----------------------- | ------- | -------------- | -------------------------------------- | +| auth | guestAccessEnabled | boolean | true | - | +| auth | userRegistrationEnabled | boolean | true | - | +| auth | maxLoginAttempts | number | 5 | 1-20 | +| auth | lockoutDurationMinutes | number | 15 | 1-1440 | +| app | siteName | string | Flashpoint Web | maxLength: 100 | +| app | maintenanceMode | boolean | false | - | +| app | defaultTheme | string | dark | light\|dark\|system | +| app | defaultPrimaryColor | string | blue | blue\|green\|red\|purple\|orange\|pink | +| metadata | autoSyncEnabled | boolean | false | - | +| metadata | syncIntervalMinutes | number | 60 | 0-1440 | +| features | enablePlaylists | boolean | true | - | +| features | enableFavorites | boolean | true | - | +| features | enableStatistics | boolean | true | - | +| features | enableActivityLog | boolean | true | - | +| game | defaultScaleMode | string | showall | showall\|exactfit\|noborder\|noscale | +| game | defaultVolume | number | 0.7 | 0-1 | ## Implementation Notes **Caching:** Settings cached 60 seconds (in-memory). Auto-invalidated on update. -**Validation:** All updates validated against JSON schemas. Type, range, and enum validation enforced. +**Validation:** All updates validated against JSON schemas. Type, range, and +enum validation enforced. ## Public Settings: Domain Injection -The `GET /api/settings/public` endpoint includes an additional `domains` object injected at runtime (not stored in the `system_settings` table): +The `GET /api/settings/public` endpoint includes an additional `domains` object +injected at runtime (not stored in the `system_settings` table): ```json { @@ -105,13 +109,15 @@ The `GET /api/settings/public` endpoint includes an additional `domains` object } ``` -This is managed via the separate `/api/domains` endpoints (see [Domains API](./domains-api.md)). +This is managed via the separate `/api/domains` endpoints (see +[Domains API](./domains-api.md)). ## Frontend Integration **Caching:** Settings cached 60 seconds (in-memory). Auto-invalidated on update. -**Validation:** All updates validated against JSON schemas. Type, range, and enum validation enforced. +**Validation:** All updates validated against JSON schemas. Type, range, and +enum validation enforced. **Frontend Integration:** @@ -127,11 +133,14 @@ const auth = await systemSettingsApi.getCategory('auth'); // Update category await systemSettingsApi.updateCategory('auth', { guestAccessEnabled: false, - maxLoginAttempts: 3 + maxLoginAttempts: 3, }); // Get single -const { value } = await systemSettingsApi.getSetting('auth', 'guestAccessEnabled'); +const { value } = await systemSettingsApi.getSetting( + 'auth', + 'guestAccessEnabled' +); // Update single await systemSettingsApi.updateSetting('auth', 'guestAccessEnabled', false); diff --git a/docs/06-api-reference/shared-playlists-api.md b/docs/06-api-reference/shared-playlists-api.md index fd9ca88..7e8f5d8 100644 --- a/docs/06-api-reference/shared-playlists-api.md +++ b/docs/06-api-reference/shared-playlists-api.md @@ -6,7 +6,8 @@ `GET /api/playlists/shared/:shareToken` - No auth required -Returns id, title, description, icon (base64), gameCount, createdAt, updatedAt, ownerUsername (if enabled). +Returns id, title, description, icon (base64), gameCount, createdAt, updatedAt, +ownerUsername (if enabled). Error: `404 Not Found` or `410 Gone` (if revoked). @@ -14,17 +15,20 @@ Error: `404 Not Found` or `410 Gone` (if revoked). `GET /api/playlists/shared/:shareToken/games` - No auth required -Returns array of game objects with id, title, platform, developer, publisher, releaseDate, thumbnail, launchCommand. +Returns array of game objects with id, title, platform, developer, publisher, +releaseDate, thumbnail, launchCommand. ### Validate Game in Shared Playlist -`GET /api/playlists/shared/:shareToken/games/:gameId/validate` - No auth required +`GET /api/playlists/shared/:shareToken/games/:gameId/validate` - No auth +required Returns `{ "valid": true|false }` ### Generate Temporary Access Token -`POST /api/playlists/shared/:shareToken/generate-access-token` - No auth required +`POST /api/playlists/shared/:shareToken/generate-access-token` - No auth +required Returns `{ "accessToken": "jwt...", "expiresIn": 3600, "playlistId": 42 }` @@ -32,11 +36,13 @@ Token valid for 60 minutes. ### Clone Shared Playlist to User Account -`POST /api/playlists/shared/:shareToken/clone` - Requires `playlists.create` permission +`POST /api/playlists/shared/:shareToken/clone` - Requires `playlists.create` +permission Body (optional): `{ "newTitle": "My Retro Games Collection" }` -Returns `201 Created` with cloned playlist details. Original playlist not modified. +Returns `201 Created` with cloned playlist details. Original playlist not +modified. ## Community Playlists @@ -44,7 +50,8 @@ Returns `201 Created` with cloned playlist details. Original playlist not modifi `GET /api/community-playlists` - Optional auth -Returns categories array with name and playlists (fetched from official Flashpoint wiki). Cached 24 hours. +Returns categories array with name and playlists (fetched from official +Flashpoint wiki). Cached 24 hours. ```json { @@ -74,7 +81,8 @@ Returns categories array with name and playlists (fetched from official Flashpoi Body: `{ "downloadUrl": "https://..." }` -Returns `201 Created` with playlist details. Authenticated users save to account; anonymous users get in-memory response. +Returns `201 Created` with playlist details. Authenticated users save to +account; anonymous users get in-memory response. ## Game Downloads @@ -92,7 +100,8 @@ Error: `409 Conflict` if already downloading or exists. `GET /api/games/:id/download/progress` - Requires `games.download` permission -Server-sent events with events: `progress` (percent, status: downloading|validating|importing), `complete`, `error`. +Server-sent events with events: `progress` (percent, status: +downloading|validating|importing), `complete`, `error`. ```javascript const eventSource = new EventSource('/api/games/game-123/download/progress'); @@ -124,7 +133,8 @@ Headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` ## Error Responses -All follow format: `{ "error": "Type", "message": "...", "code": "CODE", "statusCode": 400 }` +All follow format: +`{ "error": "Type", "message": "...", "code": "CODE", "statusCode": 400 }` Common codes: diff --git a/docs/06-api-reference/users-api.md b/docs/06-api-reference/users-api.md index 48498b3..97e2327 100644 --- a/docs/06-api-reference/users-api.md +++ b/docs/06-api-reference/users-api.md @@ -8,7 +8,8 @@ Manage user accounts and settings. Query params: `page` (default: 1), `limit` (default: 50) -Returns paginated array with id, username, email, roleId, roleName, isActive, createdAt, lastLoginAt. +Returns paginated array with id, username, email, roleId, roleName, isActive, +createdAt, lastLoginAt. ## Get User @@ -22,7 +23,8 @@ Error: `404 Not Found` `POST /api/users` - Requires `users.create` permission -Body: `{ "username": "string (3-50)", "email": "valid-email", "password": "string (min 6)", "roleId": integer, "isActive": boolean (optional) }` +Body: +`{ "username": "string (3-50)", "email": "valid-email", "password": "string (min 6)", "roleId": integer, "isActive": boolean (optional) }` Returns `201 Created` with new user. @@ -32,7 +34,8 @@ Error: `409 Conflict` if username/email exists `PATCH /api/users/:id` - Requires `users.update` permission -Body (all optional): `{ "email": "valid-email", "roleId": integer, "isActive": boolean }` +Body (all optional): +`{ "email": "valid-email", "roleId": integer, "isActive": boolean }` Error: `409 Conflict` if new email already exists @@ -46,7 +49,8 @@ Error: `400 Bad Request` if last admin ## Change Password -`POST /api/users/:id/change-password` - Users can change own, admins can change any (requires `users.update`) +`POST /api/users/:id/change-password` - Users can change own, admins can change +any (requires `users.update`) Body: `{ "currentPassword": "string", "newPassword": "string (min 6)" }` @@ -57,6 +61,7 @@ Error: `401 Unauthorized` if current password incorrect `GET /api/users/me/settings` - Requires authentication Returns key-value pairs (stored as strings). Common keys: + - theme_mode (light|dark|system) - primary_color (blue|green|red|purple|orange|pink) - game_view_mode (grid|list) diff --git a/docs/07-design-system/README.md b/docs/07-design-system/README.md index 7f4737c..80a58d8 100644 --- a/docs/07-design-system/README.md +++ b/docs/07-design-system/README.md @@ -1,6 +1,8 @@ # Design System Overview -The Flashpoint Web design system provides a consistent, accessible, and beautiful UI framework built on Tailwind CSS, Shadcn UI components, and Lucide icons. +The Flashpoint Web design system provides a consistent, accessible, and +beautiful UI framework built on Tailwind CSS, Shadcn UI components, and Lucide +icons. ## Technology Stack @@ -93,6 +95,7 @@ function Example() { ## Color System 11 semantic color roles: + - **Primary** - Brand color (22 user-customizable options) - **Secondary** - Secondary actions - **Destructive** - Errors and destructive actions @@ -107,11 +110,10 @@ function Example() { ## Component Categories -**Layout:** Card, Sheet, Dialog, Scroll Area, Separator -**Forms:** Input, Textarea, Select, Checkbox, Radio, Switch, Button -**Data:** Table, Badge, Alert, Avatar, Skeleton -**Navigation:** Dropdown Menu, Collapsible, Toggle, Toggle Group -**Feedback:** Toast, Alert Dialog, Progress +**Layout:** Card, Sheet, Dialog, Scroll Area, Separator **Forms:** Input, +Textarea, Select, Checkbox, Radio, Switch, Button **Data:** Table, Badge, Alert, +Avatar, Skeleton **Navigation:** Dropdown Menu, Collapsible, Toggle, Toggle +Group **Feedback:** Toast, Alert Dialog, Progress ## Typography @@ -123,6 +125,7 @@ function Example() { ## Spacing Scale Base unit: 4px (0.25rem) + - Common values: 4px, 8px, 12px, 16px, 24px, 32px, 48px, 64px ## Resources diff --git a/docs/07-design-system/color-palette.md b/docs/07-design-system/color-palette.md index f4ab041..54f5176 100644 --- a/docs/07-design-system/color-palette.md +++ b/docs/07-design-system/color-palette.md @@ -1,6 +1,7 @@ # Color Palette -22 color palettes with HSL values for light and dark modes. All colors meet WCAG 2.1 AA accessibility standards. +22 color palettes with HSL values for light and dark modes. All colors meet WCAG +2.1 AA accessibility standards. ## HSL Format @@ -11,57 +12,58 @@ Colors stored as HSL without `hsl()` wrapper to support opacity modifiers: ``` Usage: + ```tsx -bg-primary /* hsl(221.2 83.2% 53.3%) */ -bg-primary/50 /* hsl(221.2 83.2% 53.3% / 0.5) */ -bg-primary/20 /* hsl(221.2 83.2% 53.3% / 0.2) */ +bg - primary; /* hsl(221.2 83.2% 53.3%) */ +bg - primary / 50; /* hsl(221.2 83.2% 53.3% / 0.5) */ +bg - primary / 20; /* hsl(221.2 83.2% 53.3% / 0.2) */ ``` ## Neutral Palettes -| Palette | Light | Dark | Use Cases | -|---------|-------|------|-----------| -| **Slate** | `215.4 16.3% 46.9%` | `215.3 25% 56.9%` | Cool, modern | -| **Gray** | `220 8.9% 46.1%` | `220 13% 69%` | True neutral | -| **Zinc** | `240 3.8% 46.1%` | `240 5% 65%` | Warm, contemporary | -| **Neutral** | `0 0% 45.1%` | `0 0% 65%` | Pure grayscale | -| **Stone** | `25 5.3% 44.7%` | `33.3 5.5% 63.9%` | Beige-gray, warm | +| Palette | Light | Dark | Use Cases | +| ----------- | ------------------- | ----------------- | ------------------ | +| **Slate** | `215.4 16.3% 46.9%` | `215.3 25% 56.9%` | Cool, modern | +| **Gray** | `220 8.9% 46.1%` | `220 13% 69%` | True neutral | +| **Zinc** | `240 3.8% 46.1%` | `240 5% 65%` | Warm, contemporary | +| **Neutral** | `0 0% 45.1%` | `0 0% 65%` | Pure grayscale | +| **Stone** | `25 5.3% 44.7%` | `33.3 5.5% 63.9%` | Beige-gray, warm | ## Warm Palettes -| Palette | Light | Dark | Use Cases | -|---------|-------|------|-----------| -| **Red** | `0 72.2% 50.6%` | `0 72.2% 60.6%` | Energy, attention | -| **Orange** | `24.6 95% 53.1%` | `20.5 90.2% 58.2%` | Warm, friendly | -| **Amber** | `37.7 92.1% 50.2%` | `32.1 94.6% 58%` | Golden, inviting | -| **Yellow** | `45 93.4% 47.5%` | `47.9 95.8% 58.1%` | Cheerful, positive | +| Palette | Light | Dark | Use Cases | +| ---------- | ------------------ | ------------------ | ------------------ | +| **Red** | `0 72.2% 50.6%` | `0 72.2% 60.6%` | Energy, attention | +| **Orange** | `24.6 95% 53.1%` | `20.5 90.2% 58.2%` | Warm, friendly | +| **Amber** | `37.7 92.1% 50.2%` | `32.1 94.6% 58%` | Golden, inviting | +| **Yellow** | `45 93.4% 47.5%` | `47.9 95.8% 58.1%` | Cheerful, positive | ## Cool Palettes -| Palette | Light | Dark | Use Cases | -|---------|-------|------|-----------| -| **Lime** | `84 81% 44.3%` | `82.7 85% 55.1%` | Fresh, lively | -| **Green** | `142.1 70.6% 45.3%` | `142.1 76.2% 56.2%` | Success, growth | -| **Emerald** | `160.1 84.1% 39.4%` | `160.1 84.1% 49.4%` | Rich, luxurious | -| **Teal** | `173.4 80.4% 40%` | `172.5 66% 50.4%` | Professional, calm | -| **Cyan** | `188.7 85.7% 53.3%` | `187.2 85.7% 53.3%` | Modern, refreshing | -| **Sky** | `199.3 89.1% 48.2%` | `198.6 88.7% 58.4%` | Airy, open | -| **Blue** | `221.2 83.2% 53.3%` | `217.2 91.2% 59.8%` | Professional (default) | +| Palette | Light | Dark | Use Cases | +| ----------- | ------------------- | ------------------- | ---------------------- | +| **Lime** | `84 81% 44.3%` | `82.7 85% 55.1%` | Fresh, lively | +| **Green** | `142.1 70.6% 45.3%` | `142.1 76.2% 56.2%` | Success, growth | +| **Emerald** | `160.1 84.1% 39.4%` | `160.1 84.1% 49.4%` | Rich, luxurious | +| **Teal** | `173.4 80.4% 40%` | `172.5 66% 50.4%` | Professional, calm | +| **Cyan** | `188.7 85.7% 53.3%` | `187.2 85.7% 53.3%` | Modern, refreshing | +| **Sky** | `199.3 89.1% 48.2%` | `198.6 88.7% 58.4%` | Airy, open | +| **Blue** | `221.2 83.2% 53.3%` | `217.2 91.2% 59.8%` | Professional (default) | ## Violet/Purple Palettes -| Palette | Light | Dark | Use Cases | -|---------|-------|------|-----------| -| **Indigo** | `238.7 83.5% 66.7%` | `239.4 84.1% 76.7%` | Deep, elegant | -| **Violet** | `262.1 83.3% 57.8%` | `262.1 83.3% 67.8%` | Creative, unique | -| **Purple** | `258.3 89.5% 58.4%` | `258.3 89.5% 68.4%` | Luxury, imagination | -| **Fuchsia** | `292.2 84.1% 60.6%` | `292.2 84.1% 70.6%` | Bold, playful | +| Palette | Light | Dark | Use Cases | +| ----------- | ------------------- | ------------------- | ------------------- | +| **Indigo** | `238.7 83.5% 66.7%` | `239.4 84.1% 76.7%` | Deep, elegant | +| **Violet** | `262.1 83.3% 57.8%` | `262.1 83.3% 67.8%` | Creative, unique | +| **Purple** | `258.3 89.5% 58.4%` | `258.3 89.5% 68.4%` | Luxury, imagination | +| **Fuchsia** | `292.2 84.1% 60.6%` | `292.2 84.1% 70.6%` | Bold, playful | ## Pink/Rose Palettes -| Palette | Light | Dark | Use Cases | -|---------|-------|------|-----------| -| **Pink** | `330.4 81.2% 60.4%` | `330.4 81.2% 70.4%` | Social, friendly | +| Palette | Light | Dark | Use Cases | +| -------- | ------------------- | ------------------- | ----------------- | +| **Pink** | `330.4 81.2% 60.4%` | `330.4 81.2% 70.4%` | Social, friendly | | **Rose** | `346.8 77.2% 49.8%` | `346.8 77.2% 59.8%` | Romantic, elegant | ## Semantic Color Roles @@ -178,7 +180,7 @@ const { setMode } = useThemeStore(); // Toggle for testing +; ``` ### 4. Maintain Consistency diff --git a/docs/07-design-system/components-library.md b/docs/07-design-system/components-library.md index 2ffcfd2..a37c940 100644 --- a/docs/07-design-system/components-library.md +++ b/docs/07-design-system/components-library.md @@ -1,15 +1,18 @@ # Components Library -Shadcn UI components copied directly into the codebase for full customization without npm dependencies. All components are fully typed with TypeScript and support light/dark themes. +Shadcn UI components copied directly into the codebase for full customization +without npm dependencies. All components are fully typed with TypeScript and +support light/dark themes. -**Installation Approach:** Copy-paste components from Shadcn instead of npm packages. +**Installation Approach:** Copy-paste components from Shadcn instead of npm +packages. ## Form Components ### Button -6 variants: default, destructive, outline, secondary, ghost, link -4 sizes: default (h-10), sm (h-9), lg (h-11), icon (h-10 w-10) +6 variants: default, destructive, outline, secondary, ghost, link 4 sizes: +default (h-10), sm (h-9), lg (h-11), icon (h-10 w-10) ```tsx import { Button } from '@/components/ui/button'; @@ -45,7 +48,13 @@ import { Textarea } from '@/components/ui/textarea'; ### Select ```tsx -import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from '@/components/ui/select'; +; ``` ### Checkbox, Radio, Switch @@ -87,12 +96,25 @@ import { Label } from '@/components/ui/label'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; -import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form'; +import { + Form, + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from '@/components/ui/form'; -const schema = z.object({ username: z.string().min(3), email: z.string().email() }); +const schema = z.object({ + username: z.string().min(3), + email: z.string().email(), +}); function MyForm() { - const form = useForm({ resolver: zodResolver(schema), defaultValues: { username: '', email: '' } }); + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { username: '', email: '' }, + }); return (
@@ -122,7 +144,14 @@ function MyForm() { ### Card ```tsx -import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, +} from '@/components/ui/card'; @@ -131,7 +160,7 @@ import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } Content Actions - +; ``` ### Separator, Scroll Area, Collapsible @@ -154,7 +183,14 @@ import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/component ### Table ```tsx -import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; +import { + Table, + TableHeader, + TableBody, + TableRow, + TableHead, + TableCell, +} from '@/components/ui/table'; @@ -164,14 +200,14 @@ import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@ - {games.map(game => ( + {games.map((game) => ( {game.title} {game.platform} ))} -
+; ``` ### Badge @@ -187,7 +223,8 @@ import { Badge } from '@/components/ui/badge'; Action ``` -**Platform Colors:** Flash (green), HTML5 (blue), Shockwave (purple), Java (orange), Unity (slate) +**Platform Colors:** Flash (green), HTML5 (blue), Shockwave (purple), Java +(orange), Unity (slate) ### Avatar, Skeleton, Tooltip, Popover @@ -222,7 +259,13 @@ import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover ### Dropdown Menu ```tsx -import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, +} from '@/components/ui/dropdown-menu'; Menu @@ -231,7 +274,7 @@ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuIte Log out - +; ``` ### Toggle, Toggle Group @@ -253,7 +296,15 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; ### Dialog ```tsx -import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; Open @@ -267,13 +318,23 @@ import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, Dialog - +; ``` ### Alert Dialog ```tsx -import { AlertDialog, AlertDialogTrigger, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction } from '@/components/ui/alert-dialog'; +import { + AlertDialog, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogCancel, + AlertDialogAction, +} from '@/components/ui/alert-dialog'; Delete @@ -287,13 +348,19 @@ import { AlertDialog, AlertDialogTrigger, AlertDialogContent, AlertDialogHeader, Delete - +; ``` ### Sheet ```tsx -import { Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { + Sheet, + SheetTrigger, + SheetContent, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; Open @@ -302,7 +369,7 @@ import { Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle } from '@/co Title - +; ``` ## Feedback @@ -334,7 +401,11 @@ import { toast } from 'sonner'; toast.success('Saved!'); toast.error('Failed', { description: 'Details here' }); toast.info('Update available'); -toast.promise(saveGame(), { loading: 'Saving...', success: 'Saved!', error: 'Failed' }); +toast.promise(saveGame(), { + loading: 'Saving...', + success: 'Saved!', + error: 'Failed', +}); ``` ## Custom Components @@ -342,7 +413,14 @@ toast.promise(saveGame(), { loading: 'Saving...', success: 'Saved!', error: 'Fai ### Pagination ```tsx -import { Pagination, PaginationContent, PaginationItem, PaginationPrevious, PaginationLink, PaginationNext } from '@/components/ui/pagination'; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationPrevious, + PaginationLink, + PaginationNext, +} from '@/components/ui/pagination'; @@ -350,13 +428,15 @@ import { Pagination, PaginationContent, PaginationItem, PaginationPrevious, Pagi - 1 + + 1 + - +; ``` ### Platform Icon @@ -371,12 +451,16 @@ import { PlatformIcon } from '@/components/ui/platform-icon'; ## Best Practices 1. **Use Semantic Components** - Prefer Card/Button over generic divs -2. **Use asChild Prop** - For polymorphic components: `` -3. **Controlled vs Uncontrolled** - Use state for complex logic, uncontrolled for simple cases -4. **Accessibility** - Always add labels, ARIA attributes, and sr-only text for icon-only buttons +2. **Use asChild Prop** - For polymorphic components: + `` +3. **Controlled vs Uncontrolled** - Use state for complex logic, uncontrolled + for simple cases +4. **Accessibility** - Always add labels, ARIA attributes, and sr-only text for + icon-only buttons --- -See [Shadcn UI Docs](https://ui.shadcn.com) for complete component API reference. +See [Shadcn UI Docs](https://ui.shadcn.com) for complete component API +reference. Next: [Icons](./icons.md) - Lucide icons usage diff --git a/docs/07-design-system/design-patterns.md b/docs/07-design-system/design-patterns.md index a71e389..08e0350 100644 --- a/docs/07-design-system/design-patterns.md +++ b/docs/07-design-system/design-patterns.md @@ -18,9 +18,7 @@ Common UI patterns and best practices for Flashpoint Web.
-
- {children} -
+
{children}
``` @@ -65,7 +63,7 @@ const [open, setOpen] = useState(false); - +; ``` ## Card Patterns @@ -75,8 +73,14 @@ const [open, setOpen] = useState(false); ```tsx
- {title} - {platform} + {title} + + {platform} +
@@ -89,7 +93,10 @@ const [open, setOpen] = useState(false); {releaseDate} - +
``` @@ -130,7 +137,12 @@ const [isLoading, setIsLoading] = useState(false);
- +
- +; ``` ### Filter Form @@ -148,7 +160,9 @@ const [isLoading, setIsLoading] = useState(false); Filters - + @@ -184,16 +198,24 @@ const [isLoading, setIsLoading] = useState(false); ```tsx
- {items.map(item => ( -
+ {items.map((item) => ( +
- {item.initials} + + + {item.initials} +

{item.title}

{item.subtitle}

- +
))}
@@ -207,7 +229,10 @@ const [isLoading, setIsLoading] = useState(false); Games Library
- +
@@ -220,19 +245,29 @@ const [isLoading, setIsLoading] = useState(false); - {games.map(game => ( + {games.map((game) => ( {game.title} - {game.platform} + + {game.platform} + - + - Play + + + Play + - Delete + + + Delete + @@ -251,16 +286,24 @@ const [isLoading, setIsLoading] = useState(false); ```tsx - + Delete {gameName}? - This action cannot be undone. + + This action cannot be undone. + Cancel - + Delete @@ -275,21 +318,31 @@ const [open, setOpen] = useState(false); - + Edit Game -
- {/* Form fields */} -
+
{/* Form fields */}
- - + +
-
+; ``` ## Loading States @@ -322,7 +375,9 @@ const [open, setOpen] = useState(false);

No games found

-

Try adjusting your search or filters.

+

+ Try adjusting your search or filters. +

- + + @@ -163,7 +170,11 @@ function GameCard({ game }) { return (
- {game.title} + {game.title}
@@ -174,7 +185,9 @@ function GameCard({ game }) { - +
); @@ -327,6 +340,7 @@ All interactive elements should be 44px × 44px on mobile: ## Testing Breakpoints Always test at: + - 320px (small mobile) - 375px (mobile) - 768px (tablet) @@ -336,7 +350,8 @@ Always test at: ## Best Practices -1. **Mobile-First Always** - Write mobile styles first, add breakpoint prefixes for larger screens +1. **Mobile-First Always** - Write mobile styles first, add breakpoint prefixes + for larger screens 2. **Progressive Enhancement** - Basic on mobile, enhanced on desktop 3. **Avoid Overuse** - Use 2-3 breakpoints, not every breakpoint 4. **Test All Breakpoints** - Don't assume it works without testing diff --git a/docs/07-design-system/spacing-layout.md b/docs/07-design-system/spacing-layout.md index 22f076c..540d59f 100644 --- a/docs/07-design-system/spacing-layout.md +++ b/docs/07-design-system/spacing-layout.md @@ -7,13 +7,13 @@ Tailwind CSS spacing scale for consistent layouts and visual rhythm. Base unit: **4px (0.25rem)** | Class | Pixels | Class | Pixels | Class | Pixels | -|-------|--------|-------|--------|-------|--------| -| `0` | 0px | `8` | 32px | `24` | 96px | -| `1` | 4px | `10` | 40px | `32` | 128px | -| `2` | 8px | `12` | 48px | `40` | 160px | -| `3` | 12px | `14` | 56px | `48` | 192px | -| `4` | 16px | `16` | 64px | `64` | 256px | -| `6` | 24px | `20` | 80px | `96` | 384px | +| ----- | ------ | ----- | ------ | ----- | ------ | +| `0` | 0px | `8` | 32px | `24` | 96px | +| `1` | 4px | `10` | 40px | `32` | 128px | +| `2` | 8px | `12` | 48px | `40` | 160px | +| `3` | 12px | `14` | 56px | `48` | 192px | +| `4` | 16px | `16` | 64px | `64` | 256px | +| `6` | 24px | `20` | 80px | `96` | 384px | ## Padding & Margin @@ -146,22 +146,16 @@ Base unit: **4px (0.25rem)** ```tsx
-
- {/* Header content */} -
+
{/* Header content */}

Page Title

-
- {/* Page sections */} -
+
{/* Page sections */}
-
- {/* Footer */} -
+
{/* Footer */}
``` @@ -194,7 +188,9 @@ Base unit: **4px (0.25rem)**
- {games.map(game => )} + {games.map((game) => ( + + ))}
``` @@ -216,6 +212,7 @@ Max-width container with responsive padding: ``` **Max Widths:** + - Mobile: 100% (with padding) - 2xl (1400px+): 1400px fixed width @@ -329,9 +326,7 @@ Max-width container with responsive padding: ```tsx // Increase spacing on larger screens -
- Content -
+
Content
``` --- diff --git a/docs/07-design-system/theme-system.md b/docs/07-design-system/theme-system.md index de615a2..aa42dec 100644 --- a/docs/07-design-system/theme-system.md +++ b/docs/07-design-system/theme-system.md @@ -1,6 +1,7 @@ # Theme System -Dynamic theme system with light/dark modes and 22 customizable color palettes. Uses CSS variables for instant theme switching with Zustand state management. +Dynamic theme system with light/dark modes and 22 customizable color palettes. +Uses CSS variables for instant theme switching with Zustand state management. ## Architecture @@ -15,6 +16,7 @@ User Preferences → Zustand Store → CSS Variables → UI ## Theme Modes ### Light Mode + Clean, bright interface for daylight viewing. ```tsx @@ -25,12 +27,14 @@ setMode('light'); ``` **Characteristics:** + - Light backgrounds (98% lightness) - Dark text on light backgrounds - Subtle shadows - Vibrant primary colors ### Dark Mode + Eye-friendly interface for low-light viewing. ```tsx @@ -38,19 +42,22 @@ setMode('dark'); ``` **Characteristics:** + - Deep backgrounds (4.9% lightness) - Light text on dark - Reduced brightness - Slightly muted primary colors ### System Mode (Auto) + Automatically matches OS preference. ```tsx setMode('system'); ``` -Listens to `prefers-color-scheme` media query and updates when system preference changes. +Listens to `prefers-color-scheme` media query and updates when system preference +changes. ## Primary Color Customization @@ -66,12 +73,9 @@ setPrimaryColor('green'); setPrimaryColor('rose'); ``` -**Available Colors:** -Neutral: Slate, Gray, Zinc, Neutral, Stone -Warm: Red, Orange, Amber, Yellow -Cool: Lime, Green, Emerald, Teal, Cyan, Sky, Blue -Purple: Indigo, Violet, Purple, Fuchsia -Pink: Pink, Rose +**Available Colors:** Neutral: Slate, Gray, Zinc, Neutral, Stone Warm: Red, +Orange, Amber, Yellow Cool: Lime, Green, Emerald, Teal, Cyan, Sky, Blue Purple: +Indigo, Violet, Purple, Fuchsia Pink: Pink, Rose ## CSS Variables @@ -83,7 +87,7 @@ All theme values use CSS custom properties for dynamic updates. :root { --background: 0 0% 98%; --foreground: 222.2 84% 4.9%; - --primary: 221.2 83.2% 53.3%; /* Blue default */ + --primary: 221.2 83.2% 53.3%; /* Blue default */ --secondary: 210 40% 96.1%; --destructive: 0 84.2% 60.2%; --muted: 210 40% 96.1%; @@ -100,7 +104,7 @@ All theme values use CSS custom properties for dynamic updates. .dark { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; - --primary: 217.2 91.2% 59.8%; /* Lighter blue */ + --primary: 217.2 91.2% 59.8%; /* Lighter blue */ --secondary: 217.2 32.6% 17.5%; --destructive: 0 62.8% 30.6%; --border: 217.2 32.6% 17.5%; @@ -140,13 +144,8 @@ interface ThemeState { import { useThemeStore } from '@/store/theme'; function ThemeSettings() { - const { - mode, - setMode, - primaryColor, - setPrimaryColor, - isLoading - } = useThemeStore(); + const { mode, setMode, primaryColor, setPrimaryColor, isLoading } = + useThemeStore(); return (
@@ -156,7 +155,10 @@ function ThemeSettings() { - setPrimaryColor(e.target.value)} + > @@ -190,6 +192,7 @@ function Header() { ``` **Features:** + - 3 options (Light, Dark, System) - Icon changes based on current mode - Keyboard accessible @@ -213,6 +216,7 @@ function Settings() { ``` **Features:** + - Visual color swatches - Grouped by category - Live preview @@ -248,6 +252,7 @@ const settings = await usersApi.getThemeSettings(); ``` Endpoints: + - `GET /api/users/me/theme` - Fetch - `PUT /api/users/me/theme` - Update @@ -292,7 +297,8 @@ document.documentElement.style.setProperty('--ring', colorValue); Listens to OS-level theme changes in "system" mode: ```typescript -window.matchMedia('(prefers-color-scheme: dark)') +window + .matchMedia('(prefers-color-scheme: dark)') .addEventListener('change', (e) => { const state = useThemeStore.getState(); if (state.mode === 'system') { @@ -303,6 +309,7 @@ window.matchMedia('(prefers-color-scheme: dark)') ``` Supports: + - Windows dark mode toggle - macOS dark mode schedule - Linux desktop themes @@ -327,6 +334,7 @@ Focus rings use primary color for consistency: ### Color Contrast All palettes meet WCAG 2.1 AA: + - Normal text: 4.5:1 ratio - Large text: 3:1 ratio - Interactive: 3:1 ratio @@ -365,7 +373,7 @@ const { setMode } = useThemeStore(); +; ``` ### 3. Respect User Preferences @@ -386,12 +394,12 @@ Design components to work in any theme: ```tsx // Good: Works in any theme - - Content - +Content; // Bad: Theme-specific components -{mode === 'dark' ? : } +{ + mode === 'dark' ? : ; +} ``` ## Troubleshooting diff --git a/docs/07-design-system/typography.md b/docs/07-design-system/typography.md index d6f9aaa..96271d5 100644 --- a/docs/07-design-system/typography.md +++ b/docs/07-design-system/typography.md @@ -1,10 +1,12 @@ # Typography -Inter font family with 12 text sizes, 4 weights, and optimized line heights. All typography meets WCAG accessibility standards. +Inter font family with 12 text sizes, 4 weights, and optimized line heights. All +typography meets WCAG accessibility standards. ## Font Family -**Inter** - Modern sans-serif designed for UI with excellent character differentiation. +**Inter** - Modern sans-serif designed for UI with excellent character +differentiation. Fallback chain: Inter → system-ui → Helvetica → Arial → sans-serif @@ -13,24 +15,25 @@ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; ``` Loaded with Google Fonts optimizations: + - `preconnect` for faster loading - `display=swap` to prevent invisible text - Only 400, 500, 600, 700 weights ## Font Sizes -| Class | Size | Pixels | Use Case | -|-------|------|--------|----------| -| `text-xs` | 0.75rem | 12px | Labels, badges | -| `text-sm` | 0.875rem | 14px | Small text, captions | -| `text-base` | 1rem | 16px | **Body text (default)** | -| `text-lg` | 1.125rem | 18px | Emphasized body | -| `text-xl` | 1.25rem | 20px | Small headings | -| `text-2xl` | 1.5rem | 24px | Card titles, h3 | -| `text-3xl` | 1.875rem | 30px | Section headings, h2 | -| `text-4xl` | 2.25rem | 36px | Page titles, h1 | -| `text-5xl` | 3rem | 48px | Large displays | -| `text-6xl` | 3.75rem | 60px | Hero text | +| Class | Size | Pixels | Use Case | +| ----------- | -------- | ------ | ----------------------- | +| `text-xs` | 0.75rem | 12px | Labels, badges | +| `text-sm` | 0.875rem | 14px | Small text, captions | +| `text-base` | 1rem | 16px | **Body text (default)** | +| `text-lg` | 1.125rem | 18px | Emphasized body | +| `text-xl` | 1.25rem | 20px | Small headings | +| `text-2xl` | 1.5rem | 24px | Card titles, h3 | +| `text-3xl` | 1.875rem | 30px | Section headings, h2 | +| `text-4xl` | 2.25rem | 36px | Page titles, h1 | +| `text-5xl` | 3rem | 48px | Large displays | +| `text-6xl` | 3.75rem | 60px | Hero text | Usage: @@ -45,12 +48,12 @@ Usage: ## Font Weights -| Class | Weight | Use Case | -|-------|--------|----------| -| `font-normal` | 400 | Body text, paragraphs | -| `font-medium` | 500 | Labels, buttons | -| `font-semibold` | 600 | Headings | -| `font-bold` | 700 | Strong emphasis | +| Class | Weight | Use Case | +| --------------- | ------ | --------------------- | +| `font-normal` | 400 | Body text, paragraphs | +| `font-medium` | 500 | Labels, buttons | +| `font-semibold` | 600 | Headings | +| `font-bold` | 700 | Strong emphasis | Usage: @@ -67,13 +70,23 @@ Tailwind applies optimal line heights automatically: ```css /* Headings get tighter */ -.text-4xl { line-height: 1.1; } -.text-3xl { line-height: 1.2; } -.text-2xl { line-height: 1.3; } +.text-4xl { + line-height: 1.1; +} +.text-3xl { + line-height: 1.2; +} +.text-2xl { + line-height: 1.3; +} /* Body text gets comfortable */ -.text-base { line-height: 1.5; } -.text-sm { line-height: 1.43; } +.text-base { + line-height: 1.5; +} +.text-sm { + line-height: 1.43; +} ``` Explicit utilities: diff --git a/docs/08-development/README.md b/docs/08-development/README.md index 5e17d42..73fd144 100644 --- a/docs/08-development/README.md +++ b/docs/08-development/README.md @@ -1,13 +1,17 @@ # Development Documentation -This directory contains comprehensive documentation for developers working on the Flashpoint Web application. +This directory contains comprehensive documentation for developers working on +the Flashpoint Web application. ## Contents 1. **[Setup Guide](./setup-guide.md)** - Complete development environment setup -2. **[Commands Reference](./commands.md)** - All npm commands for monorepo and services -3. **[Project Structure](./project-structure.md)** - Directory structure explained -4. **[Coding Standards](./coding-standards.md)** - TypeScript conventions and style guide +2. **[Commands Reference](./commands.md)** - All npm commands for monorepo and + services +3. **[Project Structure](./project-structure.md)** - Directory structure + explained +4. **[Coding Standards](./coding-standards.md)** - TypeScript conventions and + style guide 5. **[Testing Guide](./testing-guide.md)** - Testing approach and best practices 6. **[Debugging Guide](./debugging.md)** - Debugging tips and troubleshooting 7. **[Common Pitfalls](./common-pitfalls.md)** - Known issues and solutions @@ -52,7 +56,8 @@ Each service can be developed, tested, and deployed independently. - Review existing documentation in this directory - Check the main [CLAUDE.md](../../CLAUDE.md) for project overview -- Consult service-specific docs in `docs/03-backend`, `docs/04-frontend`, and `docs/05-game-service` +- Consult service-specific docs in `docs/03-backend`, `docs/04-frontend`, and + `docs/05-game-service` - Review architecture documentation in `docs/02-architecture` ## Contributing diff --git a/docs/08-development/coding-standards.md b/docs/08-development/coding-standards.md index d80379b..f80b014 100644 --- a/docs/08-development/coding-standards.md +++ b/docs/08-development/coding-standards.md @@ -5,9 +5,12 @@ Coding standards and best practices for Flashpoint Web development. ## TypeScript Standards ### Strict Mode -All services use TypeScript strict mode with `noUnusedLocals`, `noUnusedParameters`, and `noFallthroughCasesInSwitch` enabled. + +All services use TypeScript strict mode with `noUnusedLocals`, +`noUnusedParameters`, and `noFallthroughCasesInSwitch` enabled. ### Type Annotations + - Use explicit return types for public functions - Use type inference for obvious cases (const count = 0) - Prefer interfaces over type aliases for object shapes @@ -16,18 +19,22 @@ All services use TypeScript strict mode with `noUnusedLocals`, `noUnusedParamete - Prefer union types over enums (unless you need reverse mapping) ### Example: + ```typescript // Good export function getGameById(id: string): Game | null { - return games.find(g => g.id === id) ?? null; + return games.find((g) => g.id === id) ?? null; } // Good - interfaces for objects, unions for types -interface User { id: string; name: string; } +interface User { + id: string; + name: string; +} type Status = 'active' | 'inactive'; // Avoid -export function getGameById(id: string) { } // implicit return type +export function getGameById(id: string) {} // implicit return type ``` --- @@ -35,11 +42,15 @@ export function getGameById(id: string) { } // implicit return type ## Naming Conventions ### Files -- **Backend**: Services `PascalCase.ts`, routes `kebab-case.ts`, middleware/utils `camelCase.ts` -- **Frontend**: Components `PascalCase.tsx`, hooks `useXxxx.ts`, stores/utils `camelCase.ts` + +- **Backend**: Services `PascalCase.ts`, routes `kebab-case.ts`, + middleware/utils `camelCase.ts` +- **Frontend**: Components `PascalCase.tsx`, hooks `useXxxx.ts`, stores/utils + `camelCase.ts` - **Game Service**: All files `kebab-case.ts` ### Variables & Functions + - Variables/functions: `camelCase` - Constants: `SCREAMING_SNAKE_CASE` - Private properties: prefix with underscore `_cache` @@ -55,6 +66,7 @@ export function getGameById(id: string) { } // implicit return type ## Code Organization ### Import Order + 1. Node.js built-in modules 2. External packages (alphabetical) 3. Internal modules with `@` alias (alphabetical) @@ -62,9 +74,12 @@ export function getGameById(id: string) { } // implicit return type 5. Type imports (separate) ### Exports -- Prefer named exports (except React components where default export is acceptable) + +- Prefer named exports (except React components where default export is + acceptable) ### File Structure + Services: imports → types/interfaces → constants → class → helper functions React components: imports → types → constants → component → helper components @@ -74,6 +89,7 @@ React components: imports → types → constants → component → helper compo ## ESLint Rules ### Backend (.eslintrc.json) + ```json { "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], @@ -83,6 +99,7 @@ React components: imports → types → constants → component → helper compo ``` ### Frontend (.eslintrc.json) + ```json { "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], @@ -98,7 +115,9 @@ Run with `npm run lint` or auto-fix with `npm run lint -- --fix` ## Comment Standards ### Documentation Comments + Use JSDoc for public APIs: + ```typescript /** * Retrieves a game by its ID from the database. @@ -111,6 +130,7 @@ export async function getGameById(id: string): Promise { ``` ### Inline Comments + - Explain "why", not "what" - Use TODO, FIXME, HACK prefixes for issues - Avoid obvious comments @@ -120,6 +140,7 @@ export async function getGameById(id: string): Promise { ## Error Handling ### Backend + ```typescript // Use try-catch for async operations try { @@ -134,6 +155,7 @@ try { ``` ### Frontend + ```typescript // React Query error handling const { data, error, isError } = useQuery({ queryKey: ['games'], queryFn: fetchGames }); @@ -145,26 +167,29 @@ if (isError) return
Failed: {error.message}
; ## Backend Standards ### Database + - Use prepared statements to prevent SQL injection - Use transactions for multi-step operations - Never write to flashpoint.sqlite (read-only) - Use UserDatabaseService for writes to user.db ### Service Layer + ```typescript export class GameService { // Simple queries return null for not found - public getGameById(id: string): Game | null { } + public getGameById(id: string): Game | null {} // List queries return empty arrays - public searchGames(query: GameQuery): Game[] { } + public searchGames(query: GameQuery): Game[] {} // Mutations throw errors on failure - public async updateGame(id: string, updates: Partial): Promise { } + public async updateGame(id: string, updates: Partial): Promise {} } ``` ### Routes + - Validate input with Zod - Return consistent response structure - Use 404 for not found, 400 for validation, 500 for server errors @@ -175,23 +200,28 @@ export class GameService { ## Frontend Standards ### React Components + - Prefer function components - Extract custom hooks for reusable logic - Destructure props in function signature -- Use appropriate state management (React Query for server state, Zustand for UI state) +- Use appropriate state management (React Query for server state, Zustand for UI + state) ### API Calls + **CRITICAL**: Use authenticated axios instance from `frontend/src/lib/api.ts` + ```typescript // CORRECT - authenticated import { api } from '@/lib/api'; const { data } = await api.get('/api/games'); // WRONG - no authentication -const response = await fetch('/api/games'); // ❌ Bypasses auth! +const response = await fetch('/api/games'); // ❌ Bypasses auth! ``` ### Styling + - Group related Tailwind classes - Use `clsx` for conditional classes - Extract common styles to constants @@ -201,6 +231,7 @@ const response = await fetch('/api/games'); // ❌ Bypasses auth! ## Git Commit Standards ### Format + ``` (): @@ -210,6 +241,7 @@ const response = await fetch('/api/games'); // ❌ Bypasses auth! ``` ### Types + - `feat`: New feature - `fix`: Bug fix - `docs`: Documentation @@ -219,6 +251,7 @@ const response = await fetch('/api/games'); // ❌ Bypasses auth! - `chore`: Maintenance ### Examples + ``` feat(backend): add game favoriting functionality fix(frontend): resolve infinite scroll pagination bug @@ -226,6 +259,7 @@ docs(development): add coding standards documentation ``` ### Branches + - `feature/` - `bugfix/` - `docs/` diff --git a/docs/08-development/commands.md b/docs/08-development/commands.md index c3c75f7..e160bce 100644 --- a/docs/08-development/commands.md +++ b/docs/08-development/commands.md @@ -7,6 +7,7 @@ All npm commands available in Flashpoint Web monorepo. Run from repository root. ### Development + ```bash npm run dev # Start all services (concurrent) npm run dev:backend # Backend only @@ -15,6 +16,7 @@ npm run dev:game-service # Game service only ``` ### Installation + ```bash npm run install:all # Install all service dependencies npm run install:backend # Backend only @@ -23,6 +25,7 @@ npm run install:game-service # Game service only ``` ### Building + ```bash npm run build # Build all services for production npm run build:backend @@ -31,6 +34,7 @@ npm run build:game-service ``` ### Type Checking + ```bash npm run typecheck # Type check all services npm run typecheck:backend @@ -39,6 +43,7 @@ npm run typecheck:game-service ``` ### Production + ```bash npm start # Start backend + game-service in production npm run start:backend @@ -46,6 +51,7 @@ npm run start:game-service ``` ### Code Formatting + ```bash npm run format # Format all files with Prettier npm run format:check # Check without changes @@ -55,6 +61,7 @@ npm run format:game-service ``` ### Cleanup + ```bash npm run clean # Remove all build artifacts + node_modules npm run clean:backend @@ -69,18 +76,21 @@ npm run clean:game-service Run from `backend/` directory. ### Development + ```bash npm run dev # Start with hot reload npm run kill-port # Kill process on port 3100 (Windows) ``` ### Building & Production + ```bash npm run build # Compile TypeScript to dist/ npm start # Run production build ``` ### Code Quality + ```bash npm run typecheck # Type check only npm run lint # Check code with ESLint @@ -88,6 +98,7 @@ npm run lint -- --fix # Auto-fix issues ``` ### Testing + ```bash npm test # Run all tests npm test -- --watch # Watch mode @@ -103,17 +114,20 @@ npm test -- --grep "pattern" # Pattern matching Run from `frontend/` directory. ### Development + ```bash npm run dev # Start Vite dev server (http://localhost:5173) ``` ### Building & Preview + ```bash npm run build # Build for production (frontend/dist/) npm run preview # Preview production build locally (http://localhost:4173) ``` ### Code Quality + ```bash npm run typecheck # Type check only npm run lint # Check code with ESLint @@ -121,6 +135,7 @@ npm run lint -- --fix # Auto-fix issues ``` ### Utilities + ```bash npm run copy-ruffle # Copy Ruffle emulator to public/ruffle/ ``` @@ -132,6 +147,7 @@ npm run copy-ruffle # Copy Ruffle emulator to public/ruffle/ Run from `game-service/` directory. ### Development + ```bash npm run dev # Start with hot reload # Proxy: http://localhost:22500 @@ -139,12 +155,14 @@ npm run dev # Start with hot reload ``` ### Building & Production + ```bash npm run build # Compile TypeScript to dist/ npm start # Run production build ``` ### Code Quality + ```bash npm run typecheck # Type check only ``` @@ -156,6 +174,7 @@ npm run typecheck # Type check only Run from repository root. ### Building + ```bash docker-compose build # Build all services docker-compose build backend # Specific service @@ -163,6 +182,7 @@ docker-compose build --no-cache # Clean build ``` ### Running + ```bash docker-compose up -d # Start all (background) docker-compose up # Start all (foreground) @@ -171,6 +191,7 @@ docker-compose up -d --build # Rebuild and restart ``` ### Monitoring & Management + ```bash docker-compose logs -f # View all logs docker-compose logs -f backend # Specific service @@ -183,6 +204,7 @@ docker-compose exec backend npm test # Run command in container ``` ### Prerequisites + ```bash # Set Flashpoint path before running Docker export FLASHPOINT_HOST_PATH=/path/to/Flashpoint @@ -196,6 +218,7 @@ $env:FLASHPOINT_HOST_PATH = "D:\Flashpoint" ## Common Workflows ### First Time Setup + ```bash npm run install:all cp backend/.env.example backend/.env @@ -206,6 +229,7 @@ npm run dev ``` ### Before Committing + ```bash npm run typecheck cd backend && npm run lint && npm test @@ -213,6 +237,7 @@ npm run build ``` ### Debugging Build Issues + ```bash npm run clean npm run install:all @@ -221,6 +246,7 @@ npm run build ``` ### Production Deployment + ```bash npm run build export NODE_ENV=production @@ -232,24 +258,28 @@ npm start ## Environment Variables ### Backend (.env) -| Variable | Required | Default | Notes | -|----------|----------|---------|-------| -| `FLASHPOINT_PATH` | Yes | - | All other paths derived automatically | -| `JWT_SECRET` | Yes* | - | *Required in production | -| `DOMAIN` | No | http://localhost:5173 | Frontend URL for CORS | -| `GAME_SERVICE_HOST` | No | localhost | Game service hostname | -| `LOG_LEVEL` | No | info | error, warn, info, debug | -| `NODE_ENV` | No | development | production in production | + +| Variable | Required | Default | Notes | +| ------------------- | -------- | --------------------- | ------------------------------------- | +| `FLASHPOINT_PATH` | Yes | - | All other paths derived automatically | +| `JWT_SECRET` | Yes\* | - | \*Required in production | +| `DOMAIN` | No | http://localhost:5173 | Frontend URL for CORS | +| `GAME_SERVICE_HOST` | No | localhost | Game service hostname | +| `LOG_LEVEL` | No | info | error, warn, info, debug | +| `NODE_ENV` | No | development | production in production | ### Game Service (.env) -| Variable | Required | Default | Notes | -|----------|----------|---------|-------| -| `FLASHPOINT_PATH` | Yes | - | All other paths derived automatically | -| `LOG_LEVEL` | No | info | error, warn, info, debug | -| `EXTERNAL_FALLBACK_URLS` | No | - | Comma-separated CDN URLs | + +| Variable | Required | Default | Notes | +| ------------------------ | -------- | ------- | ------------------------------------- | +| `FLASHPOINT_PATH` | Yes | - | All other paths derived automatically | +| `LOG_LEVEL` | No | info | error, warn, info, debug | +| `EXTERNAL_FALLBACK_URLS` | No | - | Comma-separated CDN URLs | ### Frontend -No environment variables required for local development. In production, Nginx proxies to backend. + +No environment variables required for local development. In production, Nginx +proxies to backend. --- diff --git a/docs/08-development/common-pitfalls.md b/docs/08-development/common-pitfalls.md index 44d5bac..24df934 100644 --- a/docs/08-development/common-pitfalls.md +++ b/docs/08-development/common-pitfalls.md @@ -5,12 +5,15 @@ Known issues and solutions for Flashpoint Web development. ## Environment Setup Issues ### Flashpoint Path Not Found + **Symptom:** + ``` Error: ENOENT: no such file or directory, open 'D:/Flashpoint/Data/flashpoint.sqlite' ``` **Solution:** + ```bash # Verify installation exists ls D:/Flashpoint/Data/flashpoint.sqlite # Linux/Mac @@ -30,13 +33,16 @@ ls $FLASHPOINT_PATH/Data/flashpoint.sqlite --- ### Node Version Mismatch + **Symptom:** + ``` Error: The engine "node" is incompatible with this module Expected version ">=20.0.0". Got "18.12.0" ``` **Solution:** + ```bash node --version # Check current version @@ -50,12 +56,15 @@ nvm use 20 --- ### Dependencies Won't Install + **Symptom:** + ``` npm ERR! ERESOLVE unable to resolve dependency tree ``` **Solution:** + ```bash npm cache clean --force npm run clean # Remove all node_modules @@ -68,12 +77,15 @@ npm run install:all -- --legacy-peer-deps # If still fails ## Database Issues ### Database Locked Error + **Symptom:** + ``` SqliteError: database is locked ``` **Solution:** + ```bash # Close Flashpoint Launcher (it locks flashpoint.sqlite) # Check for open processes @@ -88,6 +100,7 @@ npm run dev ``` **Prevention:** + ```typescript class DatabaseService { private static instance: DatabaseService; @@ -113,12 +126,15 @@ class DatabaseService { --- ### Migration Fails + **Symptom:** + ``` Error: Migration failed: table users already exists ``` **Solution:** + ```bash # Check current schema sqlite3 backend/user.db ".schema" @@ -135,16 +151,20 @@ npm run dev --- ### Read-Only Database Error + **Symptom:** + ``` Error: attempt to write a readonly database ``` **Causes:** + - Writing to flashpoint.sqlite (read-only, managed by Flashpoint Launcher) - Incorrect permissions on user.db **Solution:** + ```bash # NEVER write to flashpoint.sqlite @@ -164,12 +184,15 @@ userDb.createUser(...); # ✓ Correct ## Port Conflicts ### Port Already in Use + **Symptom:** + ``` Error: listen EADDRINUSE: address already in use :::3100 ``` **Solution:** + ```bash # Find process using port lsof -i :3100 # Linux/Mac @@ -191,12 +214,15 @@ PORT=3002 ## Authentication Issues ### JWT Token Expired + **Symptom:** + ``` 401 Unauthorized: Token expired ``` **Solution:** + ```typescript api.interceptors.response.use( (response) => response, @@ -206,7 +232,7 @@ api.interceptors.response.use( try { const { data } = await api.post('/api/auth/refresh', { - refreshToken: localStorage.getItem('refreshToken') + refreshToken: localStorage.getItem('refreshToken'), }); localStorage.setItem('accessToken', data.accessToken); error.config.headers['Authorization'] = `Bearer ${data.accessToken}`; @@ -224,12 +250,15 @@ api.interceptors.response.use( --- ### CORS Errors + **Symptom:** + ``` Access to XMLHttpRequest blocked by CORS policy ``` **Solution:** + ```typescript // Backend import cors from 'cors'; @@ -251,10 +280,13 @@ const { data } = await api.get('/api/games'); # ✓ Has auth headers ## Game Service Issues ### Game Files Not Loading + **Symptom:** + - Blank game screen or 404 errors **Solution:** + ```bash # Verify game-service is running curl http://localhost:22500/health @@ -273,12 +305,15 @@ curl http://localhost:22500/file.swf --- ### Ruffle Not Loading + **Symptom:** + ``` Error: Ruffle is not defined ``` **Solution:** + ```bash # Verify files exist ls frontend/public/ruffle/ @@ -301,12 +336,15 @@ npm run copy-ruffle ## Frontend Build Issues ### Build Fails with Type Errors + **Symptom:** + ``` ERROR: Type error: Property 'xyz' does not exist on type 'ABC' ``` **Solution:** + ```bash cd frontend @@ -322,10 +360,13 @@ npm run typecheck --- ### Vite HMR Not Working + **Symptom:** + - Changes not reflecting in browser, manual refresh needed **Solution:** + ```bash # Verify dev server running curl http://localhost:5173 @@ -346,10 +387,13 @@ sudo sysctl -p --- ### Tailwind Styles Not Applied + **Symptom:** + - Tailwind classes not working **Solution:** + ```bash # Verify tailwind.config.js includes all source files # content should match './src/**/*.{js,ts,jsx,tsx}' @@ -372,10 +416,13 @@ npm run dev ## Performance Issues ### Slow Database Queries + **Symptom:** + - API responses taking >1s **Solution:** + ```sql -- Add indexes CREATE INDEX idx_game_platform ON game(platform); @@ -392,10 +439,13 @@ SELECT * FROM game WHERE title LIKE '%mario%'; # Full scan --- ### Frontend Memory Leaks + **Symptom:** + - High memory usage, slow interactions **Solutions:** + ```tsx // 1. Virtualize long lists import { useVirtualizer } from '@tanstack/react-virtual'; @@ -418,16 +468,16 @@ const GamePlayer = lazy(() => import('./GamePlayer')); ## Quick Reference: Error Messages -| Error | Service | Quick Fix | -|-------|---------|----------| -| `ENOENT: no such file` | Backend | Check FLASHPOINT_PATH in .env | -| `EADDRINUSE` | Any | Kill process or change port | -| `database is locked` | Backend | Close Flashpoint Launcher | -| `Token expired` | Backend | Implement token refresh | -| `CORS policy` | Frontend | Check DOMAIN in backend .env | -| `Ruffle is not defined` | Frontend | Run `npm run copy-ruffle` | -| `Module not found` | Any | Run `npm install` | -| `Type error` | Frontend/Backend | Run `npm run typecheck` | +| Error | Service | Quick Fix | +| ----------------------- | ---------------- | ----------------------------- | +| `ENOENT: no such file` | Backend | Check FLASHPOINT_PATH in .env | +| `EADDRINUSE` | Any | Kill process or change port | +| `database is locked` | Backend | Close Flashpoint Launcher | +| `Token expired` | Backend | Implement token refresh | +| `CORS policy` | Frontend | Check DOMAIN in backend .env | +| `Ruffle is not defined` | Frontend | Run `npm run copy-ruffle` | +| `Module not found` | Any | Run `npm install` | +| `Type error` | Frontend/Backend | Run `npm run typecheck` | --- diff --git a/docs/08-development/database-migrations.md b/docs/08-development/database-migrations.md index 36c0726..396aabf 100644 --- a/docs/08-development/database-migrations.md +++ b/docs/08-development/database-migrations.md @@ -1,10 +1,13 @@ # Database Migrations -This document describes the database migration system for the user database (`user.db`). +This document describes the database migration system for the user database +(`user.db`). ## Overview -The migration system uses a **registry-based approach** to track which migrations have been applied to the database. This ensures consistent schema evolution across all installations. +The migration system uses a **registry-based approach** to track which +migrations have been applied to the database. This ensures consistent schema +evolution across all installations. ## Architecture @@ -25,7 +28,8 @@ CREATE TABLE migrations ( ### Migration Files -Migration files are located in `backend/src/migrations/` and follow the naming convention: +Migration files are located in `backend/src/migrations/` and follow the naming +convention: ``` XXX_description.sql @@ -38,44 +42,53 @@ XXX_description.sql ### Current Active Migrations -| File | Description | -|------|-------------| -| `bootstrap.sql` | Creates the migrations registry table | +| File | Description | +| ------------------------- | ------------------------------------------------------ | +| `bootstrap.sql` | Creates the migrations registry table | | `001_complete_schema.sql` | Complete database schema with all tables and seed data | ### Archived Migrations -Historical migration files (001-016 from the old system) are archived in `backend/src/migrations/archived/` for reference. These files are **no longer executed** but document the evolution of the database schema. +Historical migration files (001-016 from the old system) are archived in +`backend/src/migrations/archived/` for reference. These files are **no longer +executed** but document the evolution of the database schema. ## How It Works ### Startup Flow -When the backend starts, `UserDatabaseService.initialize()` performs these steps: +When the backend starts, `UserDatabaseService.initialize()` performs these +steps: 1. **Bootstrap** - Creates the `migrations` table if it doesn't exist -2. **Detect Existing State** - For backward compatibility, detects if the database already has tables and marks migrations as applied -3. **Run Migrations** - Scans the migrations directory and runs any migrations not yet in the registry -4. **Record Execution** - Logs each migration with timestamp, checksum, and execution time +2. **Detect Existing State** - For backward compatibility, detects if the + database already has tables and marks migrations as applied +3. **Run Migrations** - Scans the migrations directory and runs any migrations + not yet in the registry +4. **Record Execution** - Logs each migration with timestamp, checksum, and + execution time ### Backward Compatibility -The system automatically detects existing databases (from the old migration system) and marks the consolidated migrations as applied: +The system automatically detects existing databases (from the old migration +system) and marks the consolidated migrations as applied: ```typescript // Checks for existing tables and seed data -if (tables.some(t => t.name === 'users')) { +if (tables.some((t) => t.name === 'users')) { markApplied('001_complete_schema', 'Detected existing schema and seed data'); } ``` -This ensures a smooth transition from the old ad-hoc migration system to the new registry-based system. +This ensures a smooth transition from the old ad-hoc migration system to the new +registry-based system. ## Adding New Migrations ### Step 1: Create Migration File -Create a new SQL file in `backend/src/migrations/` with the next sequential number: +Create a new SQL file in `backend/src/migrations/` with the next sequential +number: ```bash # Example: Adding a new feature @@ -150,18 +163,21 @@ Once a migration has been applied to any database: - **Never** rename the file - **Never** delete the file -If you need to make changes, create a **new migration** that modifies the schema. +If you need to make changes, create a **new migration** that modifies the +schema. ### 3. Use Descriptive Names Migration names should clearly indicate what they do: ✅ Good: + - `003_add_user_badges.sql` - `004_remove_deprecated_settings.sql` - `005_add_game_tags_index.sql` ❌ Bad: + - `003_changes.sql` - `004_fix.sql` - `005_update.sql` @@ -274,11 +290,13 @@ WHERE field NOT LIKE 'new_format%'; -- Idempotent check ### Migration Fails on Existing Database -**Symptom:** Migration runs successfully on fresh database but fails on existing database. +**Symptom:** Migration runs successfully on fresh database but fails on existing +database. **Cause:** Non-idempotent SQL (e.g., `CREATE TABLE` without `IF NOT EXISTS`) **Solution:** Add idempotency checks: + ```sql CREATE TABLE IF NOT EXISTS ... INSERT OR IGNORE INTO ... @@ -289,6 +307,7 @@ INSERT OR IGNORE INTO ... **Symptom:** Migration is in the file system but not in `migrations` table. **Diagnosis:** + ```sql -- Check what migrations are registered SELECT name, applied_at FROM migrations ORDER BY applied_at; @@ -297,13 +316,15 @@ SELECT name, applied_at FROM migrations ORDER BY applied_at; ls backend/src/migrations/*.sql ``` -**Solution:** The migration may not have run yet. Restart the backend to trigger migration execution. +**Solution:** The migration may not have run yet. Restart the backend to trigger +migration execution. ### Checksum Mismatch **Symptom:** Migration was applied but someone modified the SQL file. **Detection:** + ```sql SELECT name, checksum FROM migrations WHERE name = '003_my_migration'; ``` @@ -311,6 +332,7 @@ SELECT name, checksum FROM migrations WHERE name = '003_my_migration'; Then compute the checksum of the current file and compare. **Solution:** + 1. If the change was intentional, create a new migration with the corrected SQL 2. If the change was accidental, restore the original SQL from git history 3. Never modify applied migrations @@ -322,6 +344,7 @@ Then compute the checksum of the current file and compare. **Cause:** Another process (e.g., Flashpoint Launcher) has the database locked. **Solution:** + 1. Close the Flashpoint Launcher 2. Ensure no other backend processes are running 3. Restart the backend @@ -381,7 +404,8 @@ If you need to roll back a migration: ### Reset Migration Registry -**Warning:** This should only be done on development databases, never in production. +**Warning:** This should only be done on development databases, never in +production. ```sql -- Delete all migration records @@ -413,7 +437,8 @@ DROP TABLE migrations; ## References -- Migration Registry Implementation: `backend/src/services/UserDatabaseService.ts` +- Migration Registry Implementation: + `backend/src/services/UserDatabaseService.ts` - Migration Files: `backend/src/migrations/` - Archived Migrations: `backend/src/migrations/archived/` - Database Schema Reference: `docs/12-reference/database-schema-reference.md` diff --git a/docs/08-development/debugging.md b/docs/08-development/debugging.md index 880fc02..7eb122b 100644 --- a/docs/08-development/debugging.md +++ b/docs/08-development/debugging.md @@ -5,7 +5,9 @@ Debugging Flashpoint Web across all services. ## Development Tools ### VS Code Debug Configuration + Create `.vscode/launch.json`: + ```json { "version": "0.2.0", @@ -31,6 +33,7 @@ Create `.vscode/launch.json`: ``` ### Recommended VS Code Extensions + - ESLint, Prettier, TypeScript Language Features - Debugger for Chrome, REST Client - SQLite Viewer, GitLens @@ -40,6 +43,7 @@ Create `.vscode/launch.json`: ## Backend Debugging ### Start with Debugger + ```bash # Method 1: VS Code - F5 or select "Backend: Debug" @@ -53,6 +57,7 @@ npm run dev -- --inspect ``` ### Logging + ```typescript // src/utils/logger.ts import winston from 'winston'; @@ -71,16 +76,18 @@ export LOG_LEVEL=debug npm run dev ``` **Logging best practices:** + ```typescript logger.info('User login', { username, ip: req.ip }); logger.error('Operation failed', { error, context: additionalData }); ``` ### Database Debugging + ```typescript // Enable SQL logging const db = new Database(dbPath, { - verbose: (sql: string) => logger.debug('SQL', { sql }) + verbose: (sql: string) => logger.debug('SQL', { sql }), }); // Inspect query results @@ -89,6 +96,7 @@ logger.debug('Query result', { sql: stmt.source, result }); ``` ### Testing Requests + ```bash # GET request curl http://localhost:3100/api/games @@ -110,10 +118,12 @@ curl -v http://localhost:3100/api/games ## Frontend Debugging ### React DevTools + - Install "React Developer Tools" extension for Chrome/Firefox - Inspect component tree, props, state, hooks in DevTools ### React Query DevTools + ```tsx // App.tsx import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; @@ -129,6 +139,7 @@ export default function App() { ``` ### API Call Debugging + ```typescript // lib/api.ts api.interceptors.request.use((config) => { @@ -149,6 +160,7 @@ api.interceptors.response.use( ``` ### Component Debugging + ```tsx export function GameCard({ game }: GameCardProps) { console.log('GameCard render', { game }); @@ -163,7 +175,9 @@ export function GameCard({ game }: GameCardProps) { ``` ### Performance Profiling + Use Chrome DevTools: + 1. Open DevTools > Performance 2. Click Record 3. Interact with application @@ -175,11 +189,13 @@ Use Chrome DevTools: ## Database Debugging ### SQLite Browser + - DB Browser for SQLite (free): https://sqlitebrowser.org/ - SQLiteStudio or TablePlus for GUI inspection - VS Code: SQLite Viewer extension ### Query Testing + ```bash # Open database sqlite3 backend/user.db @@ -197,12 +213,14 @@ SELECT * FROM play_sessions WHERE user_id = 'abc'; ``` ### Performance Analysis + ```sql EXPLAIN QUERY PLAN SELECT * FROM game WHERE platform = 'Flash' AND library = 'arcade'; ``` ### Database Locks + ```bash # Check for locks lsof backend/user.db # Linux/Mac @@ -224,11 +242,13 @@ try { ## Game Service Debugging ### Enable Debug Logging + ```bash LOG_LEVEL=debug npm run dev ``` ### Test Proxy + ```bash # Test proxy server curl http://localhost:22500/health @@ -245,6 +265,7 @@ curl http://localhost:22501/health ## Common Issues ### Port Already in Use + ```bash # Find process lsof -i :3100 # Linux/Mac @@ -256,6 +277,7 @@ taskkill /PID /F # Windows ``` ### CORS Errors + ```bash # Check CORS headers in response curl -v http://localhost:3100/api/games | grep Access-Control @@ -269,6 +291,7 @@ app.use(cors({ ``` ### Build Errors + ```bash npm run typecheck # Check types npm run build # Build all diff --git a/docs/08-development/project-structure.md b/docs/08-development/project-structure.md index ec66508..3c52aa0 100644 --- a/docs/08-development/project-structure.md +++ b/docs/08-development/project-structure.md @@ -74,6 +74,7 @@ backend/ ``` **Patterns:** + - Services: `PascalCase.ts` (e.g., `GameService.ts`) - Routes: `kebab-case.ts` (e.g., `play-tracking.ts`) - Middleware/Utils: `camelCase.ts` @@ -152,6 +153,7 @@ frontend/ ``` **Patterns:** + - Components: `PascalCase.tsx` - Hooks: `useXxxx.ts` - Stores: `camelCase.ts` @@ -222,6 +224,7 @@ docs/ ## Configuration Files ### Root + - `package.json` - Monorepo scripts - `package-lock.json` - Dependency lock - `docker-compose.yml` - Docker services @@ -229,12 +232,14 @@ docs/ - `README.md` - Project overview ### Backend + - `tsconfig.json` - TypeScript config - `.eslintrc.json` - ESLint rules - `vitest.config.ts` - Test config - `.env.example` - Environment template ### Frontend + - `tsconfig.json` - TypeScript config (app) - `tsconfig.node.json` - TypeScript config (Vite) - `vite.config.ts` - Vite bundler @@ -243,6 +248,7 @@ docs/ - `.eslintrc.json` - ESLint rules ### Game Service + - `tsconfig.json` - TypeScript config - `.env.example` - Environment template @@ -251,6 +257,7 @@ docs/ ## Data Directories ### Flashpoint Installation (FLASHPOINT_PATH) + ``` D:/Flashpoint/ ├── Data/ @@ -265,6 +272,7 @@ D:/Flashpoint/ ``` ### Application Data + ``` backend/ ├── user.db # User database @@ -296,17 +304,20 @@ import { api } from '@/lib/api'; ## Build Output ### Backend (dist/) + - server.js - Entry point - Compiled JavaScript files - Type declarations (.d.ts) - Source maps (.js.map) ### Frontend (dist/) + - index.html - Entry point - assets/ - Minified JS, CSS, hashed bundles - ruffle/ - Ruffle emulator files ### Game Service (dist/) + - index.js - Entry point - Compiled JavaScript files diff --git a/docs/08-development/setup-guide.md b/docs/08-development/setup-guide.md index 1ae12db..3d02b1d 100644 --- a/docs/08-development/setup-guide.md +++ b/docs/08-development/setup-guide.md @@ -7,22 +7,26 @@ Complete guide to setting up Flashpoint Web development environment. ### Required Software **Node.js v20.0.0+** + ```bash node --version # Verify nvm install 20 && nvm use 20 # Install with nvm ``` **npm v9.0.0+** (included with Node.js) + ```bash npm --version # Verify ``` **Git** + ```bash git --version # Verify ``` **Flashpoint Archive** + - Download from [flashpointarchive.org](https://flashpointarchive.org/) - Default path: `D:\Flashpoint` (Windows) or `~/Flashpoint` (Linux/Mac) - Verify: `ls D:/Flashpoint/Data/flashpoint.sqlite` @@ -32,12 +36,14 @@ git --version # Verify ## Initial Setup ### 1. Clone Repository + ```bash git clone https://github.com/darkraise/flashpoint-web.git cd flashpoint-web ``` ### 2. Install Dependencies + ```bash npm run install:all @@ -45,12 +51,14 @@ npm run install:all ``` ### 3. Configure Backend + ```bash cd backend cp .env.example .env ``` Edit `backend/.env`: + ```bash FLASHPOINT_PATH=D:/Flashpoint JWT_SECRET=change-this-in-production @@ -62,15 +70,18 @@ LOG_LEVEL=info NODE_ENV=development ``` -**Note:** All paths (database, images, logos, htdocs, games) are automatically derived from `FLASHPOINT_PATH`. +**Note:** All paths (database, images, logos, htdocs, games) are automatically +derived from `FLASHPOINT_PATH`. ### 4. Configure Game Service + ```bash cd game-service cp .env.example .env ``` Edit `game-service/.env`: + ```bash FLASHPOINT_PATH=D:/Flashpoint LOG_LEVEL=info @@ -78,6 +89,7 @@ EXTERNAL_FALLBACK_URLS=http://infinity.flashpointarchive.org/Flashpoint/Legacy/h ``` ### 5. Verify Setup + ```bash npm run typecheck # Type check all services npm run build # Build all services @@ -88,6 +100,7 @@ npm run build # Build all services ## Start Development ### Option A: All Services Together + ```bash npm run dev @@ -98,6 +111,7 @@ npm run dev ``` ### Option B: Individual Services + ```bash # Terminal 1: Backend npm run dev:backend @@ -110,6 +124,7 @@ npm run dev:game-service ``` ### Verify Services + ```bash # Backend curl http://localhost:3100/health # Should return {"status":"ok"} @@ -139,6 +154,7 @@ curl http://localhost:22500/health ### Visual Studio Code **Install Recommended Extensions:** + - ESLint - Prettier - TypeScript Language Features @@ -147,6 +163,7 @@ curl http://localhost:22500/health - SQLite Viewer **Settings (.vscode/settings.json):** + ```json { "editor.formatOnSave": true, @@ -158,8 +175,8 @@ curl http://localhost:22500/health } ``` -**Debug Configuration (.vscode/launch.json):** -See [Debugging Guide](./debugging.md) for complete setup. +**Debug Configuration (.vscode/launch.json):** See +[Debugging Guide](./debugging.md) for complete setup. ### WebStorm/IntelliJ @@ -182,12 +199,14 @@ cd backend && npm run lint && npm test # Lint and test ## Troubleshooting ### Port Already in Use + ```bash cd backend npm run kill-port # Windows utility ``` ### Database Not Found + ```bash # Verify Flashpoint path ls D:/Flashpoint/Data/flashpoint.sqlite @@ -197,6 +216,7 @@ cat backend/.env | grep FLASHPOINT_PATH ``` ### Dependencies Won't Install + ```bash npm cache clean --force npm run clean @@ -204,6 +224,7 @@ npm run install:all ``` ### Ruffle Not Loading + ```bash cd frontend npm run copy-ruffle @@ -219,23 +240,26 @@ See [Common Pitfalls](./common-pitfalls.md) for more issues. ## Environment Variables Reference ### Backend (.env) -| Variable | Required | Default | -|----------|----------|---------| -| `FLASHPOINT_PATH` | Yes | - | -| `JWT_SECRET` | Yes* | - | -| `DOMAIN` | No | http://localhost:5173 | -| `GAME_SERVICE_HOST` | No | localhost | -| `LOG_LEVEL` | No | info | -| `NODE_ENV` | No | development | + +| Variable | Required | Default | +| ------------------- | -------- | --------------------- | +| `FLASHPOINT_PATH` | Yes | - | +| `JWT_SECRET` | Yes\* | - | +| `DOMAIN` | No | http://localhost:5173 | +| `GAME_SERVICE_HOST` | No | localhost | +| `LOG_LEVEL` | No | info | +| `NODE_ENV` | No | development | ### Game Service (.env) -| Variable | Required | Default | -|----------|----------|---------| -| `FLASHPOINT_PATH` | Yes | - | -| `LOG_LEVEL` | No | info | -| `EXTERNAL_FALLBACK_URLS` | No | - | + +| Variable | Required | Default | +| ------------------------ | -------- | ------- | +| `FLASHPOINT_PATH` | Yes | - | +| `LOG_LEVEL` | No | info | +| `EXTERNAL_FALLBACK_URLS` | No | - | ### Frontend + No environment variables required for local development. --- diff --git a/docs/08-development/testing-guide.md b/docs/08-development/testing-guide.md index 5562c76..2872477 100644 --- a/docs/08-development/testing-guide.md +++ b/docs/08-development/testing-guide.md @@ -5,17 +5,21 @@ Testing practices for Flashpoint Web. ## Testing Philosophy ### Pyramid + - **Unit Tests (70%)**: Individual functions in isolation - **Integration Tests (20%)**: Service interactions and API endpoints - **E2E Tests (10%)**: Critical user flows ### Coverage Goals + - Backend: 80% target - Critical services: 90%+ - Routes: Test all endpoints ### When to Test + **Always:** + - Business logic in services - Database operations - Authentication and authorization @@ -24,6 +28,7 @@ Testing practices for Flashpoint Web. - Data transformations **Skip:** + - Simple getter/setter methods - Trivial UI components - Configuration files @@ -34,6 +39,7 @@ Testing practices for Flashpoint Web. ## Backend Testing (Vitest) ### Configuration + ```typescript // vitest.config.ts import { defineConfig } from 'vitest/config'; @@ -46,13 +52,14 @@ export default defineConfig({ setupFiles: ['./src/tests/setup.ts'], coverage: { provider: 'v8', - reporter: ['text', 'json', 'html'] - } - } + reporter: ['text', 'json', 'html'], + }, + }, }); ``` ### Running Tests + ```bash cd backend @@ -64,11 +71,13 @@ npm test -- --grep "authentication" # Pattern matching ``` ### Test Structure + - Co-locate tests with implementation: `GameService.test.ts` - Use fixtures for test data in `src/tests/fixtures/` - Use helpers in `src/tests/helpers/` ### Unit Test Example + ```typescript describe('GameService', () => { let gameService: GameService; @@ -82,7 +91,9 @@ describe('GameService', () => { describe('getGameById', () => { it('should return game when found', () => { const mockGame = { id: 'game-123', title: 'Test Game' }; - const mockPrepare = vi.fn().mockReturnValue({ get: vi.fn().mockReturnValue(mockGame) }); + const mockPrepare = vi + .fn() + .mockReturnValue({ get: vi.fn().mockReturnValue(mockGame) }); mockDb.prepare = mockPrepare; const result = gameService.getGameById('game-123'); @@ -91,7 +102,9 @@ describe('GameService', () => { }); it('should return null when game not found', () => { - const mockPrepare = vi.fn().mockReturnValue({ get: vi.fn().mockReturnValue(undefined) }); + const mockPrepare = vi + .fn() + .mockReturnValue({ get: vi.fn().mockReturnValue(undefined) }); mockDb.prepare = mockPrepare; const result = gameService.getGameById('nonexistent'); @@ -103,6 +116,7 @@ describe('GameService', () => { ``` ### Integration Test Example + ```typescript describe('Games API', () => { let authToken: string; @@ -114,9 +128,7 @@ describe('Games API', () => { describe('GET /api/games', () => { it('should return list of games', async () => { - const response = await request(app) - .get('/api/games') - .expect(200); + const response = await request(app).get('/api/games').expect(200); expect(response.body).toHaveProperty('data'); expect(Array.isArray(response.body.data)).toBe(true); @@ -126,6 +138,7 @@ describe('Games API', () => { ``` ### Test Data Fixtures + ```typescript // src/tests/fixtures/games.ts export const testGames = { @@ -133,8 +146,8 @@ export const testGames = { id: 'flash-1', title: 'Flash Game', platform: 'Flash', - library: 'arcade' - } + library: 'arcade', + }, }; export function createTestGame(overrides = {}) { @@ -145,6 +158,7 @@ export function createTestGame(overrides = {}) { ### Testing Patterns **Async Code:** + ```typescript it('should fetch games asynchronously', async () => { const games = await service.fetchGames(); @@ -153,6 +167,7 @@ it('should fetch games asynchronously', async () => { ``` **Error Handling:** + ```typescript it('should throw error on invalid input', () => { expect(() => validateGameData({ title: '' })).toThrow('Title is required'); @@ -160,9 +175,10 @@ it('should throw error on invalid input', () => { ``` **Mocking:** + ```typescript vi.mock('@/utils/jwt', () => ({ - verifyToken: vi.fn() + verifyToken: vi.fn(), })); const mockVerify = vi.mocked(verifyToken); @@ -173,7 +189,9 @@ mockVerify.mockReturnValue({ userId: '123' }); ## Frontend Testing -Frontend testing setup is planned but not yet implemented. When implemented, it will use: +Frontend testing setup is planned but not yet implemented. When implemented, it +will use: + - **Vitest**: Test runner - **React Testing Library**: Component testing - **MSW**: API mocking @@ -184,13 +202,16 @@ Frontend testing setup is planned but not yet implemented. When implemented, it ## Coverage ### Running Coverage Reports + ```bash cd backend npm test -- --coverage ``` ### Coverage Thresholds + Add to `vitest.config.ts`: + ```typescript export default defineConfig({ test: { @@ -199,10 +220,10 @@ export default defineConfig({ statements: 80, branches: 75, functions: 80, - lines: 80 - } - } - } + lines: 80, + }, + }, + }, }); ``` @@ -211,6 +232,7 @@ export default defineConfig({ ## Best Practices ### Do's + - Write tests before fixing bugs (regression testing) - Test one thing per test - Use descriptive test names ("should return null when game not found") @@ -220,6 +242,7 @@ export default defineConfig({ - Test edge cases and error paths ### Don'ts + - Don't test implementation details - Don't make tests dependent on each other - Don't use production databases diff --git a/docs/09-deployment/README.md b/docs/09-deployment/README.md index 55dbf04..5f2ecb0 100644 --- a/docs/09-deployment/README.md +++ b/docs/09-deployment/README.md @@ -1,19 +1,24 @@ # Deployment Documentation -Complete deployment guides for Flashpoint Web in development, staging, and production environments. +Complete deployment guides for Flashpoint Web in development, staging, and +production environments. ## Quick Links -- **[Docker Deployment](./docker-deployment.md)** - Container-based deployment with Docker Compose -- **[Environment Variables](./environment-variables.md)** - Complete environment configuration reference +- **[Docker Deployment](./docker-deployment.md)** - Container-based deployment + with Docker Compose +- **[Environment Variables](./environment-variables.md)** - Complete environment + configuration reference - **[Health Checks](./health-checks.md)** - Health check setup and monitoring -- **[Security Considerations](./security-considerations.md)** - Production security hardening and best practices +- **[Security Considerations](./security-considerations.md)** - Production + security hardening and best practices ## Deployment Options ### Docker Deployment (Recommended) -Best for quick deployment, consistent environments, easy scaling, and isolated service containers. +Best for quick deployment, consistent environments, easy scaling, and isolated +service containers. **Quick Start:** @@ -40,7 +45,8 @@ npm run install:all npm run dev ``` -Configuration: Hot reload, debug logging, lenient rate limits, local Flashpoint path. +Configuration: Hot reload, debug logging, lenient rate limits, local Flashpoint +path. ### Staging Deployment @@ -62,21 +68,26 @@ NODE_ENV=production npm run build docker-compose -f docker-compose.prod.yml up -d ``` -Configuration: Optimized builds, strict rate limits, valid SSL certificate, full monitoring and alerting, automated backups, log rotation. +Configuration: Optimized builds, strict rate limits, valid SSL certificate, full +monitoring and alerting, automated backups, log rotation. ## Infrastructure Overview **Minimum (< 100 users):** + - 2 CPU cores, 4GB RAM, 50GB storage - Ports: 80, 443 (public); 3100, 22500, 22501 (internal) **Recommended (100-500 users):** + - 4-8 CPU cores, 8-16GB RAM, 500GB SSD storage **Large (500+ users):** + - 8+ CPU cores, 16-32GB RAM, 1TB+ SSD storage, load balancer, Redis cache, CDN **Storage Planning:** + - Full Flashpoint Archive: ~600GB - User database: < 100MB - Application logs: ~1GB/month @@ -95,5 +106,6 @@ After deployment: ## Related Documentation - [API Reference](../06-api-reference/README.md) - Complete API endpoints -- [Architecture Overview](../02-architecture/system-architecture.md) - System design +- [Architecture Overview](../02-architecture/system-architecture.md) - System + design - [Common Pitfalls](../08-development/common-pitfalls.md) - Troubleshooting diff --git a/docs/09-deployment/docker-deployment.md b/docs/09-deployment/docker-deployment.md index d3c12eb..c426eb8 100644 --- a/docs/09-deployment/docker-deployment.md +++ b/docs/09-deployment/docker-deployment.md @@ -16,6 +16,7 @@ Deploy Flashpoint Web using Docker Compose. ## Quick Start 1. Set Flashpoint path: + ```bash # Linux/Mac export FLASHPOINT_HOST_PATH=/path/to/flashpoint @@ -25,11 +26,13 @@ $env:FLASHPOINT_HOST_PATH="D:\Flashpoint" ``` 2. Start services: + ```bash docker-compose up -d --build ``` 3. Access: + - Frontend: http://localhost - Backend API: http://localhost:3100 - Game Proxy: http://localhost:22500 @@ -37,13 +40,14 @@ docker-compose up -d --build ## Container Architecture -| Service | Image | Port | Purpose | -|---------|-------|------|---------| -| backend | flashpoint-backend:latest | 3100 | REST API | +| Service | Image | Port | Purpose | +| ------------ | ------------------------------ | ----------- | ----------------------- | +| backend | flashpoint-backend:latest | 3100 | REST API | | game-service | flashpoint-game-service:latest | 22500/22501 | Game proxy + ZIP server | -| frontend | flashpoint-frontend:latest | 80 | Web UI (Nginx) | +| frontend | flashpoint-frontend:latest | 80 | Web UI (Nginx) | **Resource Recommendations:** + - Backend: 1 CPU, 1GB RAM - Game Service: 2 CPUs, 2GB RAM - Frontend: 0.5 CPU, 256MB RAM @@ -51,12 +55,14 @@ docker-compose up -d --build ## Volume Configuration **Flashpoint Data (read-only):** + ```yaml volumes: - ${FLASHPOINT_HOST_PATH}:/data/flashpoint:ro ``` **User Database (persistent):** + ```yaml volumes: - user-db:/app/data @@ -66,14 +72,15 @@ volumes: **Port Mappings:** -| Service | Internal | Host | Notes | -|---------|----------|------|-------| -| Frontend | 8080 | 80 | Nginx | -| Backend | 3100 | 3100 | API | -| Game (Proxy) | 22500 | 22500 | HTTP | -| Game (ZIP) | 22501 | 22501 | ZIP Server | +| Service | Internal | Host | Notes | +| ------------ | -------- | ----- | ---------- | +| Frontend | 8080 | 80 | Nginx | +| Backend | 3100 | 3100 | API | +| Game (Proxy) | 22500 | 22500 | HTTP | +| Game (ZIP) | 22501 | 22501 | ZIP Server | **Custom ports** via environment variables: + ```bash export WEB_PORT=8080 export API_PORT=8001 @@ -86,7 +93,15 @@ All services include health checks: ```yaml healthcheck: - test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3100/api/health"] + test: + [ + 'CMD', + 'wget', + '--quiet', + '--tries=1', + '--spider', + 'http://localhost:3100/api/health', + ] interval: 30s timeout: 10s retries: 3 @@ -94,24 +109,30 @@ healthcheck: ``` Check status: + ```bash docker-compose ps ``` ## Nginx Configuration -The frontend uses a template-based configuration with environment variable substitution. +The frontend uses a template-based configuration with environment variable +substitution. **Key files:** + - `frontend/.nginx/nginx.conf` - Global settings (worker processes, logging) -- `frontend/.nginx/default.conf.template` - Server block with `${BACKEND_HOST}`, `${BACKEND_PORT}` variables +- `frontend/.nginx/default.conf.template` - Server block with `${BACKEND_HOST}`, + `${BACKEND_PORT}` variables **At runtime:** + 1. Docker entrypoint runs `envsubst` to substitute variables 2. Processed config written to `/etc/nginx/conf.d/default.conf` 3. Nginx starts with final configuration **Features:** + - SPA routing (serves index.html for non-static routes) - Gzip compression - Security headers (HSTS, CSP, X-Frame-Options) @@ -120,16 +141,19 @@ The frontend uses a template-based configuration with environment variable subst ## Running Containers Start all services: + ```bash docker-compose up -d ``` Stop services: + ```bash docker-compose down ``` Restart with rebuild: + ```bash docker-compose up -d --build --force-recreate ``` @@ -137,6 +161,7 @@ docker-compose up -d --build --force-recreate ## Monitoring and Logs View logs: + ```bash docker-compose logs -f backend docker-compose logs -f game-service @@ -144,11 +169,13 @@ docker-compose logs -f frontend ``` Container stats: + ```bash docker stats ``` Execute command in container: + ```bash docker-compose exec backend node -v docker-compose exec backend sh # Open shell @@ -157,6 +184,7 @@ docker-compose exec backend sh # Open shell ## Troubleshooting **Port already in use:** + ```bash # Linux/Mac lsof -i :3100 @@ -166,11 +194,13 @@ netstat -ano | findstr :3100 ``` **Volume mount failed:** + - Verify `FLASHPOINT_HOST_PATH` is set - Check path exists and is readable - On Windows, ensure Docker has drive access **Health check failing:** + ```bash # Check directly curl http://localhost:3100/api/health @@ -183,6 +213,7 @@ docker inspect flashpoint-backend --format='{{json .State.Health}}' | jq ``` **Database connection issues:** + ```bash # Verify database exists docker-compose exec backend ls -la /data/flashpoint/Data/flashpoint.sqlite @@ -192,6 +223,7 @@ docker-compose exec backend sqlite3 /data/flashpoint/Data/flashpoint.sqlite "SEL ``` **Network issues:** + ```bash # Test backend to game-service docker-compose exec backend curl http://game-service:22500/ diff --git a/docs/09-deployment/environment-variables.md b/docs/09-deployment/environment-variables.md index 9187902..efab225 100644 --- a/docs/09-deployment/environment-variables.md +++ b/docs/09-deployment/environment-variables.md @@ -8,51 +8,52 @@ Location: `backend/.env` **Server:** -| Variable | Default | Description | -|----------|---------|-------------| -| `NODE_ENV` | development | Environment: development, production, test | -| `PORT` | 3100 | HTTP server port | -| `HOST` | 0.0.0.0 | Bind address (use 127.0.0.1 for localhost only) | +| Variable | Default | Description | +| ---------- | ----------- | ----------------------------------------------- | +| `NODE_ENV` | development | Environment: development, production, test | +| `PORT` | 3100 | HTTP server port | +| `HOST` | 0.0.0.0 | Bind address (use 127.0.0.1 for localhost only) | **Paths (only set FLASHPOINT_PATH):** -| Variable | Description | -|----------|-------------| +| Variable | Description | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------- | | `FLASHPOINT_PATH` | Root Flashpoint directory. All other paths auto-derived. Edition (Infinity/Ultimate) auto-detected from `version.txt`. | **Game Service Host:** -| Variable | Default | Description | -|----------|---------|-------------| +| Variable | Default | Description | +| ------------------- | --------- | ------------------------------ | | `GAME_SERVICE_HOST` | localhost | Hostname for game-service URLs | **Authentication & Security:** -| Variable | Default | Description | -|----------|---------|-------------| -| `JWT_SECRET` | INSECURE-... | Secret for JWT signing (CHANGE IN PRODUCTION!) | -| `JWT_EXPIRES_IN` | 1h | Access token expiration (15m, 1h, 7d, etc.) | -| `BCRYPT_SALT_ROUNDS` | 10 | Password hash cost (higher = more secure but slower) | -| `DOMAIN` | http://localhost:5173 | CORS origin (use specific domain in production) | +| Variable | Default | Description | +| -------------------- | --------------------- | ---------------------------------------------------- | +| `JWT_SECRET` | INSECURE-... | Secret for JWT signing (CHANGE IN PRODUCTION!) | +| `JWT_EXPIRES_IN` | 1h | Access token expiration (15m, 1h, 7d, etc.) | +| `BCRYPT_SALT_ROUNDS` | 10 | Password hash cost (higher = more secure but slower) | +| `DOMAIN` | http://localhost:5173 | CORS origin (use specific domain in production) | Generate secure JWT secret: + ```bash openssl rand -hex 64 ``` **Rate Limiting:** -| Variable | Default | Description | -|----------|---------|-------------| -| `RATE_LIMIT_WINDOW_MS` | 60000 | Time window (ms) | -| `RATE_LIMIT_MAX_REQUESTS` | 100 | Max requests per window | +| Variable | Default | Description | +| ------------------------- | ------- | ----------------------- | +| `RATE_LIMIT_WINDOW_MS` | 60000 | Time window (ms) | +| `RATE_LIMIT_MAX_REQUESTS` | 100 | Max requests per window | **Logging:** -| Variable | Default | Description | -|----------|---------|-------------| -| `LOG_LEVEL` | info | Level: error, warn, info, debug | -| `LOG_FILE` | - | Optional log file path | +| Variable | Default | Description | +| ----------- | ------- | ------------------------------- | +| `LOG_LEVEL` | info | Level: error, warn, info, debug | +| `LOG_FILE` | - | Optional log file path | ## Game Service Variables @@ -60,32 +61,34 @@ Location: `game-service/.env` **Ports:** -| Variable | Default | Description | -|----------|---------|-------------| -| `PROXY_PORT` | 22500 | HTTP proxy server port | -| `GAMEZIPSERVER_PORT` | 22501 | GameZip server port | +| Variable | Default | Description | +| -------------------- | ------- | ---------------------- | +| `PROXY_PORT` | 22500 | HTTP proxy server port | +| `GAMEZIPSERVER_PORT` | 22501 | GameZip server port | **Paths:** -| Variable | Description | -|----------|-------------| +| Variable | Description | +| ----------------- | --------------------------------------------------------------- | | `FLASHPOINT_PATH` | Root Flashpoint directory (auto-derives htdocs and games paths) | **Proxy:** -| Variable | Default | Description | -|----------|---------|-------------| -| `PROXY_CHUNK_SIZE` | 8192 | File streaming chunk size (bytes) | +| Variable | Default | Description | +| ------------------------ | --------- | --------------------------------- | +| `PROXY_CHUNK_SIZE` | 8192 | File streaming chunk size (bytes) | | `EXTERNAL_FALLBACK_URLS` | See below | Comma-separated CDN fallback URLs | Default fallback URLs: + ``` http://infinity.flashpointarchive.org/Flashpoint/Legacy/htdocs,http://infinity.unstable.life/Flashpoint/Legacy/htdocs/ ``` ## Frontend Variables -The frontend has no environment variables in development or production. API calls are proxied through the backend. +The frontend has no environment variables in development or production. API +calls are proxied through the backend. - **Development**: Via Vite proxy to `http://localhost:3100` - **Production**: Via Nginx or reverse proxy @@ -96,27 +99,27 @@ Location: `.env` in project root **Core Configuration:** -| Variable | Default | Description | -|----------|---------|-------------| +| Variable | Default | Description | +| ---------------------- | ------------- | ------------------------------------ | | `FLASHPOINT_HOST_PATH` | D:/Flashpoint | Host path to Flashpoint installation | -| `NODE_ENV` | production | Environment for all services | -| `JWT_SECRET` | - | Secret for JWT signing (required) | +| `NODE_ENV` | production | Environment for all services | +| `JWT_SECRET` | - | Secret for JWT signing (required) | **Port Mapping:** -| Variable | Default | Description | -|----------|---------|-------------| -| `WEB_PORT` | 80 | Frontend exposed port | -| `API_PORT` | 3100 | Backend exposed port | -| `PROXY_PORT` | 22500 | Game proxy exposed port | -| `GAMEZIP_PORT` | 22501 | GameZip exposed port | +| Variable | Default | Description | +| -------------- | ------- | ----------------------- | +| `WEB_PORT` | 80 | Frontend exposed port | +| `API_PORT` | 3100 | Backend exposed port | +| `PROXY_PORT` | 22500 | Game proxy exposed port | +| `GAMEZIP_PORT` | 22501 | GameZip exposed port | **Other:** -| Variable | Default | Description | -|----------|---------|-------------| -| `DOMAIN` | http://localhost | CORS origin | -| `LOG_LEVEL` | info | Logging level | +| Variable | Default | Description | +| ----------- | ---------------- | ------------- | +| `DOMAIN` | http://localhost | CORS origin | +| `LOG_LEVEL` | info | Logging level | ## Docker Compose Setup @@ -177,9 +180,11 @@ LOG_FILE=/var/log/flashpoint-backend.log **Required Variables:** **Backend:** + - `FLASHPOINT_PATH` (only this is required; others auto-derive) **Docker Production:** + - `JWT_SECRET` (must be changed from default) - `DOMAIN` (must match frontend URL) @@ -199,7 +204,8 @@ node -e "require('dotenv').config(); console.log(process.env.FLASHPOINT_PATH)" 5. Limit exposed ports (don't expose all services) 6. Rotate secrets periodically 7. Use environment-specific files (.env.production, .env.development) -8. Store secrets in secure management system (AWS Secrets Manager, HashiCorp Vault) +8. Store secrets in secure management system (AWS Secrets Manager, HashiCorp + Vault) 9. Use read-only mounts for Flashpoint data in Docker ## Next Steps diff --git a/docs/09-deployment/health-checks.md b/docs/09-deployment/health-checks.md index e5780e5..efc998c 100644 --- a/docs/09-deployment/health-checks.md +++ b/docs/09-deployment/health-checks.md @@ -10,7 +10,15 @@ All services include wget-based health checks in docker-compose files. ```yaml healthcheck: - test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3100/api/health"] + test: + [ + 'CMD', + 'wget', + '--quiet', + '--tries=1', + '--spider', + 'http://localhost:3100/api/health', + ] interval: 30s timeout: 10s retries: 3 @@ -18,6 +26,7 @@ healthcheck: ``` Response: + ```json { "status": "healthy", @@ -38,7 +47,7 @@ Response: ```yaml healthcheck: - test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/"] + test: ['CMD', 'wget', '--quiet', '--tries=1', '--spider', 'http://localhost/'] interval: 30s timeout: 3s retries: 3 @@ -49,7 +58,15 @@ healthcheck: ```yaml healthcheck: - test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:22500/"] + test: + [ + 'CMD', + 'wget', + '--quiet', + '--tries=1', + '--spider', + 'http://localhost:22500/', + ] interval: 30s timeout: 10s retries: 3 @@ -100,6 +117,7 @@ backend: ``` **Sequence:** + 1. Game Service → health check → healthy (30s) 2. Backend → health check → healthy (40s) 3. Frontend → health check → healthy (10s) @@ -119,17 +137,20 @@ docker inspect flashpoint-backend --format='{{range .State.Health.Log}}{{.ExitCo **Health Check Keeps Failing:** 1. **Service not fully started**: Increase `start_period` + ```yaml healthcheck: start_period: 60s ``` 2. **Database issues**: Verify flashpoint.sqlite exists and is readable + ```bash docker-compose exec backend ls -la /data/flashpoint/Data/flashpoint.sqlite ``` 3. **Port conflicts**: Ensure ports are available + ```bash netstat -tulpn | grep -E '(3100|80|22500|22501)' ``` @@ -143,10 +164,10 @@ docker inspect flashpoint-backend --format='{{range .State.Health.Log}}{{.ExitCo ```yaml healthcheck: - interval: 60s # Check less frequently - timeout: 30s # Allow more time - retries: 5 # More retries - start_period: 90s # Longer grace period + interval: 60s # Check less frequently + timeout: 30s # Allow more time + retries: 5 # More retries + start_period: 90s # Longer grace period ``` ## Integration with Orchestrators diff --git a/docs/09-deployment/security-considerations.md b/docs/09-deployment/security-considerations.md index 0cf3e80..b7ac364 100644 --- a/docs/09-deployment/security-considerations.md +++ b/docs/09-deployment/security-considerations.md @@ -5,6 +5,7 @@ Production deployment security checklist for Flashpoint Web. ## Pre-Production Checklist **Critical Items:** + - [ ] Change default JWT secret (min 64 characters) - [ ] Set strong CORS origin (no wildcards) - [ ] Enable HTTPS with valid SSL certificate @@ -17,7 +18,8 @@ Production deployment security checklist for Flashpoint Web. - [ ] Configure Nginx reverse proxy - [ ] Set up fail2ban for intrusion detection - [ ] Enable security event logging -- [ ] Implement password complexity requirements (min 12 chars, uppercase, lowercase, numbers) +- [ ] Implement password complexity requirements (min 12 chars, uppercase, + lowercase, numbers) - [ ] Configure session timeout (1h access token, 7d refresh token) - [ ] Enable database encryption for sensitive fields - [ ] Set up encrypted backups @@ -29,32 +31,38 @@ Production deployment security checklist for Flashpoint Web. **CRITICAL: Change default JWT secret in production!** Generate 64+ character secret: + ```bash openssl rand -hex 64 ``` Set in environment: + ```bash JWT_SECRET=a1b2c3d4e5f6789012345678901234567890abcdefghijklmnopqrstuvwxyz JWT_EXPIRES_IN=1h ``` -Store in secure secret management system (AWS Secrets Manager, HashiCorp Vault, not in config files). +Store in secure secret management system (AWS Secrets Manager, HashiCorp Vault, +not in config files). ## Authentication & Authorization **Password Hashing:** + - Algorithm: bcrypt with cost 12 - Min length: 8 (recommend 12+) - Requirements: uppercase, lowercase, numbers **Permission System:** + - Admin > Moderator > User > Guest - Permissions cached 5 minutes (auto-invalidated on changes) - System roles (Admin, User, Guest) cannot be modified - All protected endpoints require explicit permissions **Session Management:** + - Access tokens: 1 hour (short-lived) - Refresh tokens: 7 days (stored in database) - Old tokens automatically revoked on refresh @@ -75,12 +83,14 @@ DOMAIN=https://flashpoint.example.com,https://www.flashpoint.example.com ## Rate Limiting Enable on all endpoints: + ```bash RATE_LIMIT_WINDOW_MS=60000 # 1 minute window RATE_LIMIT_MAX_REQUESTS=100 # 100 requests per window ``` Nginx rate limiting zones: + ```nginx limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; limit_req_zone $binary_remote_addr zone=auth:10m rate=1r/m; @@ -91,6 +101,7 @@ Auth endpoints: max 5 attempts per 15 minutes ## HTTP Security Headers Using Helmet.js: + ```typescript // Enabled in production - Strict-Transport-Security: 1 year, includeSubDomains, preload @@ -108,6 +119,7 @@ Using Helmet.js: **Ciphers:** Modern Mozilla configuration (AES-GCM, ChaCha20-Poly1305) **Certificate:** Let's Encrypt with auto-renewal: + ```bash certbot renew --dry-run # Add cron job: 0 3 * * * certbot renew --post-hook "systemctl reload nginx" --quiet @@ -118,6 +130,7 @@ certbot renew --dry-run **Non-root user:** All containers run as non-root (UID 1000) **Resource limits:** + ```yaml deploy: resources: @@ -129,17 +142,20 @@ deploy: ``` **Image scanning:** + ```bash docker scout cves flashpoint-backend:latest # or trivy image flashpoint-backend:latest ``` -**Secrets management:** Never embed in images. Use environment variables or Docker secrets. +**Secrets management:** Never embed in images. Use environment variables or +Docker secrets. ## Network Security **Firewall (UFW):** + ```bash sudo ufw allow 80/tcp sudo ufw allow 443/tcp @@ -150,15 +166,18 @@ sudo ufw enable ``` **Docker network isolation:** + - Internal networks only accessible within Docker - Services behind reverse proxy only - Backend/game-service not exposed to host -**Reverse proxy only:** All external traffic goes through Nginx, services bind to localhost. +**Reverse proxy only:** All external traffic goes through Nginx, services bind +to localhost. ## Input Validation All user input validated with Zod schemas: + - Username: 3-50 chars, alphanumeric + hyphens/underscores - Email: valid format, max 255 chars - Password: 8+ chars, uppercase, lowercase, numbers @@ -168,6 +187,7 @@ All user input validated with Zod schemas: ## Path Traversal Prevention All file paths validated: + ```typescript const resolvedPath = path.resolve(baseDir, userPath); if (!resolvedPath.startsWith(baseDir)) { @@ -180,6 +200,7 @@ URL paths sanitized before processing (null bytes, ../, backslash checks). ## SQL Injection Prevention **ALWAYS use parameterized queries:** + ```typescript // Good db.prepare('SELECT * FROM game WHERE title LIKE ?').all(`%${query}%`); @@ -189,6 +210,7 @@ db.prepare(`SELECT * FROM game WHERE title LIKE '%${query}%'`).all(); ``` **Database permissions:** + - Flashpoint database: read-only - User database: read/write by app only - No DROP/ALTER permissions @@ -197,6 +219,7 @@ db.prepare(`SELECT * FROM game WHERE title LIKE '%${query}%'`).all(); ## Logging and Monitoring **Security events logged:** + - Failed login attempts - Permission violations (403 errors) - Rate limit violations @@ -205,11 +228,13 @@ db.prepare(`SELECT * FROM game WHERE title LIKE '%${query}%'`).all(); - System configuration changes **Intrusion detection:** fail2ban with: + - Max 5 failed logins per 600s - Ban for 3600s - Monitor `/var/log/nginx/flashpoint-access.log` **Alert on:** + - Multiple failed logins from same IP - Repeated rate limit violations - Database access errors @@ -218,6 +243,7 @@ db.prepare(`SELECT * FROM game WHERE title LIKE '%${query}%'`).all(); ## Data Protection **Sensitive fields encrypted:** + ```typescript // AES-256-GCM encryption for sensitive data encrypt(text) { @@ -228,6 +254,7 @@ encrypt(text) { ``` **Backups encrypted with GPG:** + ```bash sqlite3 user.db ".backup user-db.backup" gpg --symmetric --cipher-algo AES256 user-db.backup @@ -235,6 +262,7 @@ shred -u user-db.backup # Securely delete unencrypted ``` **Data retention:** + ```sql -- Delete old play sessions DELETE FROM play_sessions WHERE created_at < datetime('now', '-90 days'); @@ -246,23 +274,27 @@ DELETE FROM audit_logs WHERE created_at < datetime('now', '-1 year'); ## Regular Maintenance **Weekly:** + - Review security logs for anomalies - Check failed login attempts - Monitor rate limit violations **Monthly:** + - Update dependencies (`npm audit`) - Review user permissions - Check SSL certificate expiration - Test backup restoration **Quarterly:** + - Rotate JWT secrets - Security audit of codebase - Penetration testing - Update firewall rules **Annually:** + - Full security assessment - Third-party audit - Disaster recovery drill diff --git a/docs/10-features/01-authentication-authorization.md b/docs/10-features/01-authentication-authorization.md index c7b21c8..f372ec0 100644 --- a/docs/10-features/01-authentication-authorization.md +++ b/docs/10-features/01-authentication-authorization.md @@ -2,11 +2,14 @@ ## Overview -JWT-based authentication system with Role-Based Access Control (RBAC). Enables secure user registration, login, session management, and fine-grained permission control across the application. +JWT-based authentication system with Role-Based Access Control (RBAC). Enables +secure user registration, login, session management, and fine-grained permission +control across the application. ## Architecture **Backend Components:** + - `AuthService`: Core authentication logic - Auth routes (routes/auth.ts): REST API endpoints - JWT utilities (utils/jwt.ts): Token generation and verification @@ -15,6 +18,7 @@ JWT-based authentication system with Role-Based Access Control (RBAC). Enables s - Auth middleware (middleware/auth.ts): JWT validation **Frontend Components:** + - `AuthContext`: React context for auth state - `LoginForm`: Login UI component - `RegisterForm`: Registration UI component @@ -25,6 +29,7 @@ JWT-based authentication system with Role-Based Access Control (RBAC). Enables s ## Database Schema **users table:** + ```sql CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -41,6 +46,7 @@ CREATE TABLE users ( ``` **refresh_tokens table:** + ```sql CREATE TABLE refresh_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -54,6 +60,7 @@ CREATE TABLE refresh_tokens ( ``` **login_attempts table:** + ```sql CREATE TABLE login_attempts ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -67,32 +74,43 @@ CREATE TABLE login_attempts ( ## API Endpoints #### POST /api/auth/register + Register a new user account. + - **Body:** `{ "username", "email", "password" }` - **Response:** User object + tokens (201) - **Validation:** username 3-50 chars, email valid format, password min 6 chars -- **Errors:** 400 validation, 403 registration disabled, 409 duplicate username/email +- **Errors:** 400 validation, 403 registration disabled, 409 duplicate + username/email #### POST /api/auth/login + Authenticate with username and password. + - **Body:** `{ "username", "password" }` - **Response:** User object + tokens - **Rate Limiting:** 5 failed attempts locks account for 15 minutes - **Errors:** 401 invalid credentials, 429 too many attempts #### POST /api/auth/logout + Revoke refresh token and logout. + - **Body:** `{ "refreshToken" }` - **Response:** `{ "success": true }` #### POST /api/auth/refresh + Refresh access token using refresh token. + - **Body:** `{ "refreshToken" }` - **Response:** New access token + new refresh token - **Note:** Old refresh token automatically revoked #### GET /api/auth/me + Get current authenticated user information. + - **Headers:** `Authorization: Bearer {accessToken}` - **Response:** User object with permissions @@ -135,25 +153,30 @@ Get current authenticated user information. ## Default Roles **Admin (priority: 100):** + - All permissions granted - Can manage users, roles, and settings **User (priority: 50):** + - games.read, games.play, games.download - playlists.read, .create, .update, .delete - Cannot manage users, roles, settings, or view activity logs **Guest (priority: 0):** + - games.read, playlists.read - Read-only access only ## Permissions -Complete permission list in [Role-Based Access Control](./07-role-permissions.md) +Complete permission list in +[Role-Based Access Control](./07-role-permissions.md) ## Configuration **Environment Variables (.env):** + ```bash JWT_SECRET=your-secret-key-here # Change in production JWT_EXPIRES_IN=1h # Token expiration @@ -162,6 +185,7 @@ DOMAIN=http://localhost:5173 # Frontend URL ``` **Auth Settings (Database):** + - `auth.guestAccessEnabled` - Allow guest browsing (default: true) - `auth.userRegistrationEnabled` - Allow new user registration (default: true) - `auth.maxLoginAttempts` - Before lockout (default: 5) @@ -170,11 +194,13 @@ DOMAIN=http://localhost:5173 # Frontend URL ## Security Features **Password Security:** + - Bcrypt hashing with 10 salt rounds - Minimum 6 character requirement - Password not returned in API responses **Token Security:** + - Access tokens expire in 1 hour - Refresh tokens expire in 30 days - Tokens use HS256 algorithm @@ -182,12 +208,14 @@ DOMAIN=http://localhost:5173 # Frontend URL - Bulk token revocation available (admin) **Rate Limiting:** + - 5 failed login attempts before lockout - 15 minute lockout duration - Tracking by both username and IP address - Old login attempts cleaned up after 24 hours **Session Security:** + - IP address and user agent tracked per session - Activity logging for audit trail - Tokens stored in localStorage (client-side) @@ -196,27 +224,30 @@ DOMAIN=http://localhost:5173 # Frontend URL ## Common Use Cases ### 1. User Registration + ```typescript const registerMutation = useRegister(); await registerMutation.mutateAsync({ username: 'johndoe', email: 'john@example.com', - password: 'secure123' + password: 'secure123', }); ``` ### 2. User Login + ```typescript const loginMutation = useLogin(); await loginMutation.mutateAsync({ username: 'johndoe', - password: 'secure123' + password: 'secure123', }); ``` ### 3. Guest Access + ```typescript const { loginAsGuest } = useAuth(); @@ -224,31 +255,39 @@ loginAsGuest(); ``` ### 4. Protected Route + ```tsx - - - -} /> + + + + } +/> ``` ### 5. Permission-Based UI + ```tsx const { hasPermission } = useAuth(); -{hasPermission('users.create') && ( - -)} +{ + hasPermission('users.create') && ( + + ); +} ``` ### 6. Change Password + ```typescript const mutation = useChangePassword(); await mutation.mutateAsync({ userId: currentUser.id, currentPassword: 'old123', - newPassword: 'new456' + newPassword: 'new456', }); ``` @@ -266,21 +305,25 @@ await mutation.mutateAsync({ ## Troubleshooting **"Invalid credentials" error:** + - Verify username and password are correct - Check if account is active (is_active = 1) - Ensure password was hashed correctly during registration **"Too many login attempts" error:** + - Wait for lockout duration to expire (15 minutes default) - Or manually clear login attempts from database - Check if IP address or username is being rate-limited **Token expired errors:** + - Implement automatic token refresh on client - Check system clock synchronization - Verify JWT expiration times in configuration **Permission denied errors:** + - Verify user has correct role assigned - Check role-permission mappings in database - Ensure permissions loaded correctly in token diff --git a/docs/10-features/02-game-browsing-filtering.md b/docs/10-features/02-game-browsing-filtering.md index c23fbd6..b1ba27e 100644 --- a/docs/10-features/02-game-browsing-filtering.md +++ b/docs/10-features/02-game-browsing-filtering.md @@ -2,16 +2,21 @@ ## Overview -Game browsing and filtering provides a comprehensive interface for discovering and exploring games from the Flashpoint Archive. Users can search, filter by multiple criteria, sort results, and navigate through paginated collections with grid and list view options. +Game browsing and filtering provides a comprehensive interface for discovering +and exploring games from the Flashpoint Archive. Users can search, filter by +multiple criteria, sort results, and navigate through paginated collections with +grid and list view options. ## Architecture **Backend Components:** + - `GameService`: Database queries and filtering logic - Game routes (routes/games.ts): REST API endpoints - Query builder with dynamic WHERE clauses **Frontend Components:** + - `BrowseView`: Main game browsing page - `FilterPanel`: Advanced filter UI - `SearchBar`: Search input component @@ -22,11 +27,13 @@ Game browsing and filtering provides a comprehensive interface for discovering a ## Features **Search:** + - Free-text search across game titles - Real-time search with debouncing (300ms) - Search persisted in URL query parameters **Filters:** + - Platform, Series, Developer, Publisher - Play Mode, Language, Tags - Year Range (1970-2100) @@ -34,15 +41,18 @@ Game browsing and filtering provides a comprehensive interface for discovering a - Show/hide broken games and extreme content **Sorting:** + - By: Title, Release Date, Date Added, Developer - Order: Ascending (A-Z) or Descending (Z-A) **Pagination:** + - Configurable items per page (default: 50, max: 100) - Page navigation controls - Total count display **View Modes:** + - Grid view (responsive 1-5 columns) - List view (compact table format) - Adjustable card size @@ -50,9 +60,11 @@ Game browsing and filtering provides a comprehensive interface for discovering a ## API Endpoints #### GET /api/games + Get paginated list of games with filters. **Query Parameters:** + - `search` - Search in titles - `platform`, `series`, `developers`, `publishers` - Comma-separated - `playModes`, `languages`, `tags` - Comma-separated @@ -65,6 +77,7 @@ Get paginated list of games with filters. - `showBroken`, `showExtreme` - Boolean toggles **Response:** + ```json { "data": [ @@ -88,25 +101,28 @@ Get paginated list of games with filters. ``` #### GET /api/games/filter-options + Get all available filter options with counts. **Response:** + ```json { "platforms": [ { "name": "Flash", "count": 85234 }, { "name": "HTML5", "count": 12456 } ], - "series": [ { "name": "Mario", "count": 234 } ], - "developers": [ { "name": "Newgrounds", "count": 5432 } ], - "publishers": [ { "name": "Armor Games", "count": 2341 } ], - "playModes": [ { "name": "Single Player", "count": 45678 } ], - "languages": [ { "name": "en", "count": 67890 } ], - "tags": [ { "name": "Puzzle", "count": 8765 } ] + "series": [{ "name": "Mario", "count": 234 }], + "developers": [{ "name": "Newgrounds", "count": 5432 }], + "publishers": [{ "name": "Armor Games", "count": 2341 }], + "playModes": [{ "name": "Single Player", "count": 45678 }], + "languages": [{ "name": "en", "count": 67890 }], + "tags": [{ "name": "Puzzle", "count": 8765 }] } ``` #### GET /api/games/:id + Get detailed information for a single game. **Response:** Game object with full metadata and tags array @@ -114,6 +130,7 @@ Get detailed information for a single game. ## Database Queries **Main Query Structure:** + ```sql SELECT g.*, p.name as platformName, (SELECT COUNT(*) FROM game_data WHERE gameId = g.id) > 0 as presentOnDisk @@ -125,6 +142,7 @@ LIMIT [limit] OFFSET [offset] ``` **Filter Conditions:** + - Search: `(g.title LIKE ? OR g.alternateTitles LIKE ?)` - Platforms: `p.name IN (?)` - Series: `g.series IN (?)` @@ -134,16 +152,19 @@ LIMIT [limit] OFFSET [offset] ## Performance Optimizations **Debouncing:** + - Search input: 300ms delay - Year inputs: 500ms delay - Prevents excessive API calls **Query Caching:** + - Filter options cached for 5 minutes - Game results cached with unique query keys - Stale-while-revalidate strategy **Database Indexes:** + ```sql CREATE INDEX idx_game_title ON game(title); CREATE INDEX idx_game_platform ON game(platformId); @@ -154,16 +175,19 @@ CREATE INDEX idx_game_release_date ON game(releaseDate); ## State Management **URL State (React Router search params):** + - All filter values, search, sort, pagination - Enables shareable URLs - Preserved on navigation **Query State (TanStack Query):** + - Game data caching - Filter options caching - Automatic refetch on filter change **UI State (Local component state):** + - Dropdown open/close states - View mode selection - Card size preference @@ -171,18 +195,21 @@ CREATE INDEX idx_game_release_date ON game(releaseDate); ## Common Use Cases ### 1. Basic Search + ```typescript // URL: ?search=mario // API: GET /api/games?search=mario&page=1&limit=50 ``` ### 2. Filter by Multiple Platforms + ```typescript // URL: ?platform=Flash,HTML5 // API: GET /api/games?platform=Flash,HTML5&page=1 ``` ### 3. Advanced Filtering + ```typescript const filters = { platform: 'Flash,HTML5', @@ -190,19 +217,18 @@ const filters = { yearFrom: 2000, yearTo: 2010, sortBy: 'releaseDate', - sortOrder: 'desc' + sortOrder: 'desc', }; ``` ### 4. Platform-Specific View + ```tsx - + ``` ### 5. Shareable Filtered Link + ``` https://flashpoint.local/browse?platform=Flash&tags=Puzzle&yearFrom=2005 ``` @@ -221,24 +247,28 @@ https://flashpoint.local/browse?platform=Flash&tags=Puzzle&yearFrom=2005 ## Troubleshooting **No results found:** + - Check if filters are too restrictive - Verify database connectivity - Check for typos in search query - Ensure filter values are valid **Slow filtering:** + - Add database indexes on filter columns - Reduce page size - Check for complex tag queries - Monitor database query performance **Filters not working:** + - Verify URL params are updating - Check API query parameter parsing - Ensure filter values are properly encoded - Validate query schema with Zod **Mobile filter issues:** + - Check responsive breakpoints - Verify popover positioning - Test touch interactions diff --git a/docs/10-features/03-game-playing.md b/docs/10-features/03-game-playing.md index 605b6c8..452ab65 100644 --- a/docs/10-features/03-game-playing.md +++ b/docs/10-features/03-game-playing.md @@ -2,17 +2,22 @@ ## Overview -Game playing enables users to play Flash and HTML5 games directly in their web browser. Flash games use the Ruffle WebAssembly emulator, while HTML5 games run natively. The player supports fullscreen mode, loading indicators, and error handling. +Game playing enables users to play Flash and HTML5 games directly in their web +browser. Flash games use the Ruffle WebAssembly emulator, while HTML5 games run +natively. The player supports fullscreen mode, loading indicators, and error +handling. ## Architecture **Backend Components:** + - Game Service: Provides game data and launch configuration - Game-Service Proxy: Serves game files (ports 22500/22501) - HTTP Proxy Server: Handles Flash content delivery - GameZip Server: Mounts and serves files from ZIP archives **Frontend Components:** + - `GamePlayer`: Main player component (reusable) - `RufflePlayer`: Ruffle Flash emulator wrapper - `GamePlayerView`: Full-page player route @@ -28,11 +33,13 @@ Game playing enables users to play Flash and HTML5 games directly in their web b ## Game Launch Process **Step 1: Fetch Launch Data** + ``` GET /api/games/:id/launch ``` Backend: + 1. Fetch game from flashpoint.sqlite 2. Check if game has data on disk 3. Auto-mount ZIP file if needed @@ -41,25 +48,30 @@ Backend: 6. Return launch configuration **Step 2: Initialize Player** + - Check if game can play in browser (canPlayInBrowser) - Select appropriate player (Ruffle vs iframe) - Mount player component **Step 3: Load Content** + - **Flash:** Ruffle loads SWF from proxy URL - **HTML5:** Iframe loads game page from proxy URL - Game service proxies requests to local files or CDN **Step 4: Track Session** + - POST /api/play/start with gameId - Backend creates play session record - Returns sessionId **Step 5: Play** + - User interacts with game - All game assets served through proxy **Step 6: End Session** + - User closes player or navigates away - POST /api/play/end with sessionId - Backend calculates duration and updates stats @@ -67,9 +79,11 @@ Backend: ## API Endpoints #### GET /api/games/:id/launch + Get game launch configuration. **Response:** + ```json { "gameId": "550e8400-e29b-41d4-a716-446655440000", @@ -84,6 +98,7 @@ Get game launch configuration. ``` **For games without content:** + ```json { "gameId": "...", @@ -100,18 +115,20 @@ Get game launch configuration. Ruffle is a Flash Player emulator written in Rust and compiled to WebAssembly. **Installation:** + - Installed from npm package `@ruffle-rs/ruffle` - Files copied to `frontend/public/ruffle/` during postinstall - Includes: ruffle.js, ruffle_web.wasm, ruffle_web.js **RufflePlayer Component:** + ```typescript interface RufflePlayerProps { - swfUrl: string; // URL to SWF file - width?: string; // Player width - height?: string; // Player height - className?: string; // CSS classes - scaleMode?: ScaleMode; // Scaling behavior + swfUrl: string; // URL to SWF file + width?: string; // Player width + height?: string; // Player height + className?: string; // CSS classes + scaleMode?: ScaleMode; // Scaling behavior onLoadError?: (error: Error) => void; onLoadSuccess?: () => void; } @@ -120,6 +137,7 @@ type ScaleMode = 'exactfit' | 'noborder' | 'showall' | 'noscale'; ``` **Ruffle Configuration:** + ```typescript const ruffleConfig = { autoplay: 'auto', @@ -146,6 +164,7 @@ HTML5 games load in a sandboxed iframe: ``` **Sandbox Permissions:** + - allow-scripts, allow-same-origin, allow-forms - allow-popups, allow-modals, allow-pointer-lock - allow-top-navigation-by-user-activation @@ -153,6 +172,7 @@ HTML5 games load in a sandboxed iframe: ## Game Service Proxy **HTTP Proxy Server (Port 22500):** + - Serves legacy web content - Fallback chain: 1. Local htdocs folder @@ -162,6 +182,7 @@ HTML5 games load in a sandboxed iframe: 5. Local cache for downloaded content **Example Request Flow:** + ``` Browser: GET http://localhost:22500/http://uploads.ungrounded.net/396000/396724_DAplayer_V6.swf Proxy: Check local htdocs, game data, mounted ZIPs, fetch from CDN if needed @@ -169,6 +190,7 @@ Response: File with correct MIME type ``` **GameZip Server (Port 22501):** + - Mounts ZIP files from Data/Games/ - Streams files without extraction - Uses node-stream-zip for efficiency @@ -198,16 +220,17 @@ useEffect(() => { ## Ruffle Scale Modes -| Mode | Behavior | Use Case | -|------|----------|----------| -| showall (default) | Maintains aspect ratio with letterboxing | Most games | -| exactfit | Stretches to fill container | Full-screen games | -| noborder | Maintains aspect ratio, crops if needed | No letterboxing needed | -| noscale | Original game dimensions | Pixel-perfect games | +| Mode | Behavior | Use Case | +| ----------------- | ---------------------------------------- | ---------------------- | +| showall (default) | Maintains aspect ratio with letterboxing | Most games | +| exactfit | Stretches to fill container | Full-screen games | +| noborder | Maintains aspect ratio, crops if needed | No letterboxing needed | +| noscale | Original game dimensions | Pixel-perfect games | ## Common Use Cases ### 1. Play Flash Game + ```tsx const navigate = useNavigate(); navigate(`/games/${gameId}/play`); @@ -216,17 +239,20 @@ navigate(`/games/${gameId}/play`); ``` ### 2. Play HTML5 Game + ```tsx // Same flow as Flash, uses iframe instead of Ruffle ``` ### 3. Fullscreen Mode + ```tsx toggleFullscreen(); // Player expands to fill screen, ESC to exit ``` ### 4. Handle Unplayable Game + ```tsx if (!launchData.canPlayInBrowser) { // Show error message @@ -236,6 +262,7 @@ if (!launchData.canPlayInBrowser) { ``` ### 5. Track Play Session + ```typescript const { startSession, endSession } = usePlayTracking(); @@ -257,6 +284,7 @@ useEffect(() => { ## Error Handling **Flash Load Errors:** + ```typescript onLoadError={(error) => { console.error('Ruffle error:', error); @@ -266,6 +294,7 @@ onLoadError={(error) => { ``` **HTML5 Load Errors:** + ```typescript