Compare commits
27 Commits
feature/ad
...
v0.10.10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
760412d7db | ||
|
|
4ab94de529 | ||
|
|
b3f89593fb | ||
|
|
2a863c51e9 | ||
|
|
a70d923a6a | ||
|
|
f660511b63 | ||
|
|
044af60699 | ||
|
|
384036bcdd | ||
|
|
ef300d233b | ||
|
|
edad65d6a9 | ||
|
|
486c972bba | ||
|
|
aab5d2ad61 | ||
|
|
1683d5c2f1 | ||
|
|
87d4af0bec | ||
|
|
35449a090d | ||
|
|
5324a41e05 | ||
|
|
ac61ba5223 | ||
|
|
56c51e5d02 | ||
|
|
1c57da9aba | ||
|
|
b2d88f1aa3 | ||
|
|
45d9681203 | ||
|
|
e259f2235a | ||
|
|
e575f641c5 | ||
|
|
bb75a8c325 | ||
|
|
99b1a2d1e9 | ||
|
|
a34b08072e | ||
|
|
8057420f09 |
@@ -8,13 +8,13 @@ env:
|
|||||||
GO_VERSION: "1.21"
|
GO_VERSION: "1.21"
|
||||||
BINARY_NAME: "acc-server-manager"
|
BINARY_NAME: "acc-server-manager"
|
||||||
MIGRATE_BINARY: "acc-server-migration"
|
MIGRATE_BINARY: "acc-server-migration"
|
||||||
API_BINARY: "api"
|
|
||||||
DEPLOY_PATH: 'C:\acc-server-manager'
|
DEPLOY_PATH: 'C:\acc-server-manager'
|
||||||
SERVICE_NAME: "ACC Server Manager"
|
SERVICE_NAME: "ACC Server Manager"
|
||||||
|
HEALTH_URL: "http://localhost:4000/v1/system/health"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: windows-latest
|
runs-on: windows
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
@@ -27,35 +27,17 @@ jobs:
|
|||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
|
env:
|
||||||
|
CGO_ENABLED: 1
|
||||||
run: go test -v ./...
|
run: go test -v ./...
|
||||||
|
|
||||||
- name: Build binaries
|
- name: Build binaries
|
||||||
|
env:
|
||||||
|
CGO_ENABLED: 1
|
||||||
run: |
|
run: |
|
||||||
mkdir -p build
|
mkdir -p build
|
||||||
go build -v -o ./build/${{ env.BINARY_NAME }}.exe ./cmd/server
|
go build -v -o ./build/${{ env.MIGRATE_BINARY }}.exe ./cmd/migrate
|
||||||
go build -v -o ./build/${{ env.MIGRATE_BINARY }}.exe ./cmd/migration
|
go build -v -o ./build/${{ env.BINARY_NAME }}.exe ./cmd/api
|
||||||
go build -v -o ./build/${{ env.API_BINARY }}.exe ./cmd/api
|
|
||||||
|
|
||||||
- name: Create release
|
|
||||||
id: create_release
|
|
||||||
uses: actions/create-release@v1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
tag_name: ${{ github.ref }}
|
|
||||||
release_name: Release ${{ github.ref }}
|
|
||||||
draft: false
|
|
||||||
prerelease: false
|
|
||||||
|
|
||||||
- name: Upload release assets
|
|
||||||
uses: actions/upload-release-asset@v1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
|
||||||
asset_path: ./build/
|
|
||||||
asset_name: acc-server-manager-${{ github.ref_name }}.zip
|
|
||||||
asset_content_type: application/zip
|
|
||||||
|
|
||||||
- name: Upload build artifacts
|
- name: Upload build artifacts
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
@@ -66,7 +48,7 @@ jobs:
|
|||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: windows-latest
|
runs-on: windows
|
||||||
environment: production
|
environment: production
|
||||||
steps:
|
steps:
|
||||||
- name: Download build artifacts
|
- name: Download build artifacts
|
||||||
@@ -113,7 +95,8 @@ jobs:
|
|||||||
# Run database migrations
|
# Run database migrations
|
||||||
Write-Host "Running database migrations..."
|
Write-Host "Running database migrations..."
|
||||||
try {
|
try {
|
||||||
& "${{ env.DEPLOY_PATH }}\${{ env.MIGRATE_BINARY }}.exe"
|
& cd "${{ env.DEPLOY_PATH }}"
|
||||||
|
& ".\${{ env.MIGRATE_BINARY }}.exe"
|
||||||
} catch {
|
} catch {
|
||||||
Write-Warning "Migration failed: $_"
|
Write-Warning "Migration failed: $_"
|
||||||
throw "Migration failed"
|
throw "Migration failed"
|
||||||
@@ -153,7 +136,7 @@ jobs:
|
|||||||
|
|
||||||
while ($attempt -le $maxAttempts -and -not $success) {
|
while ($attempt -le $maxAttempts -and -not $success) {
|
||||||
try {
|
try {
|
||||||
$response = Invoke-WebRequest -Uri "http://localhost:8080/health" -TimeoutSec 5
|
$response = Invoke-WebRequest -Uri "${{ env.HEALTH_URL }}" -TimeoutSec 5
|
||||||
if ($response.StatusCode -eq 200) {
|
if ($response.StatusCode -eq 200) {
|
||||||
Write-Host "Health check passed!"
|
Write-Host "Health check passed!"
|
||||||
$success = $true
|
$success = $true
|
||||||
@@ -168,31 +151,3 @@ jobs:
|
|||||||
if (-not $success) {
|
if (-not $success) {
|
||||||
throw "Health check failed after $maxAttempts attempts"
|
throw "Health check failed after $maxAttempts attempts"
|
||||||
}
|
}
|
||||||
|
|
||||||
- name: Notify on success
|
|
||||||
if: success()
|
|
||||||
uses: actions/github-script@v6
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const { repo, owner } = context.repo;
|
|
||||||
const release = context.ref.replace('refs/tags/', '');
|
|
||||||
await github.rest.issues.createComment({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issue_number: context.issue.number,
|
|
||||||
body: `✅ Successfully deployed ${release} to production!`
|
|
||||||
});
|
|
||||||
|
|
||||||
- name: Notify on failure
|
|
||||||
if: failure()
|
|
||||||
uses: actions/github-script@v6
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const { repo, owner } = context.repo;
|
|
||||||
const release = context.ref.replace('refs/tags/', '');
|
|
||||||
await github.rest.issues.createComment({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issue_number: context.issue.number,
|
|
||||||
body: `❌ Failed to deploy ${release} to production. Check the workflow logs for details.`
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import (
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
configs.Init()
|
configs.Init()
|
||||||
jwt.Init()
|
|
||||||
// Initialize new logging system
|
// Initialize new logging system
|
||||||
if err := logging.InitializeLogging(); err != nil {
|
if err := logging.InitializeLogging(); err != nil {
|
||||||
fmt.Printf("Failed to initialize logging system: %v\n", err)
|
fmt.Printf("Failed to initialize logging system: %v\n", err)
|
||||||
@@ -37,6 +36,8 @@ func main() {
|
|||||||
logging.InfoStartup("APPLICATION", "ACC Server Manager starting up")
|
logging.InfoStartup("APPLICATION", "ACC Server Manager starting up")
|
||||||
|
|
||||||
di := dig.New()
|
di := dig.New()
|
||||||
|
di.Provide(func() *jwt.JWTHandler { return jwt.NewJWTHandler(os.Getenv("JWT_SECRET")) })
|
||||||
|
di.Provide(func() *jwt.OpenJWTHandler { return jwt.NewOpenJWTHandler(os.Getenv("JWT_SECRET_OPEN")) })
|
||||||
cache.Start(di)
|
cache.Start(di)
|
||||||
db.Start(di)
|
db.Start(di)
|
||||||
server.Start(di)
|
server.Start(di)
|
||||||
|
|||||||
@@ -5,14 +5,14 @@
|
|||||||
// @description API for managing Assetto Corsa Competizione dedicated servers
|
// @description API for managing Assetto Corsa Competizione dedicated servers
|
||||||
//
|
//
|
||||||
// @contact.name ACC Server Manager Support
|
// @contact.name ACC Server Manager Support
|
||||||
// @contact.url https://github.com/yourusername/acc-server-manager
|
// @contact.url https://github.com/FJurmanovic/acc-server-manager
|
||||||
//
|
//
|
||||||
// @license.name MIT
|
// @license.name MIT
|
||||||
// @license.url https://opensource.org/licenses/MIT
|
// @license.url https://opensource.org/licenses/MIT
|
||||||
//
|
//
|
||||||
// @host localhost:3000
|
// @host acc-api.jurmanovic.com
|
||||||
// @BasePath /api/v1
|
// @BasePath /v1
|
||||||
// @schemes http https
|
// @schemes https
|
||||||
//
|
//
|
||||||
// @securityDefinitions.apikey BearerAuth
|
// @securityDefinitions.apikey BearerAuth
|
||||||
// @in header
|
// @in header
|
||||||
|
|||||||
93
cmd/steam-crypt/main.go
Normal file
93
cmd/steam-crypt/main.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"acc-server-manager/local/model"
|
||||||
|
"acc-server-manager/local/utl/configs"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
encrypt = flag.Bool("encrypt", false, "Encrypt a password")
|
||||||
|
decrypt = flag.Bool("decrypt", false, "Decrypt a password")
|
||||||
|
password = flag.String("password", "", "Password to encrypt/decrypt")
|
||||||
|
help = flag.Bool("help", false, "Show help")
|
||||||
|
)
|
||||||
|
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *help || (!*encrypt && !*decrypt) {
|
||||||
|
showHelp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if *encrypt && *decrypt {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: Cannot specify both -encrypt and -decrypt\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *password == "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: Password is required\n")
|
||||||
|
showHelp()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize configs to load encryption key
|
||||||
|
configs.Init()
|
||||||
|
|
||||||
|
if *encrypt {
|
||||||
|
encrypted, err := model.EncryptPassword(*password)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error encrypting password: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println(encrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *decrypt {
|
||||||
|
decrypted, err := model.DecryptPassword(*password)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error decrypting password: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Println(decrypted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func showHelp() {
|
||||||
|
fmt.Println("Steam Credentials Encryption/Decryption Utility")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("This utility encrypts and decrypts Steam credentials using the same")
|
||||||
|
fmt.Println("AES-256-GCM encryption used by the ACC Server Manager application.")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Usage:")
|
||||||
|
fmt.Println(" steam-crypt -encrypt -password \"your_password\"")
|
||||||
|
fmt.Println(" steam-crypt -decrypt -password \"encrypted_string\"")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Options:")
|
||||||
|
fmt.Println(" -encrypt Encrypt the provided password")
|
||||||
|
fmt.Println(" -decrypt Decrypt the provided encrypted string")
|
||||||
|
fmt.Println(" -password The password to encrypt or encrypted string to decrypt")
|
||||||
|
fmt.Println(" -help Show this help message")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Environment Variables Required:")
|
||||||
|
fmt.Println(" ENCRYPTION_KEY - 32-byte encryption key (same as main application)")
|
||||||
|
fmt.Println(" APP_SECRET - Application secret (required by configs)")
|
||||||
|
fmt.Println(" APP_SECRET_CODE - Application secret code (required by configs)")
|
||||||
|
fmt.Println(" ACCESS_KEY - Access key (required by configs)")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Examples:")
|
||||||
|
fmt.Println(" # Encrypt a password")
|
||||||
|
fmt.Println(" steam-crypt -encrypt -password \"mysteampassword\"")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(" # Decrypt an encrypted password")
|
||||||
|
fmt.Println(" steam-crypt -decrypt -password \"base64encryptedstring\"")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Security Notes:")
|
||||||
|
fmt.Println(" - The encryption key must be exactly 32 bytes for AES-256")
|
||||||
|
fmt.Println(" - Uses AES-256-GCM for authenticated encryption")
|
||||||
|
fmt.Println(" - Each encryption includes a unique nonce for security")
|
||||||
|
fmt.Println(" - Passwords are validated for length and basic security")
|
||||||
|
}
|
||||||
243
docs/STEAM_2FA_IMPLEMENTATION.md
Normal file
243
docs/STEAM_2FA_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
# Steam 2FA Implementation Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document describes the implementation of Steam Two-Factor Authentication (2FA) support for the ACC Server Manager. When SteamCMD requires 2FA confirmation during server installation or updates, the system now signals the frontend and waits for user confirmation before proceeding.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The 2FA implementation consists of several interconnected components:
|
||||||
|
|
||||||
|
### Backend Components
|
||||||
|
|
||||||
|
1. **Steam2FAManager** (`local/model/steam_2fa.go`)
|
||||||
|
- Thread-safe management of 2FA requests
|
||||||
|
- Request lifecycle tracking (pending → complete/error)
|
||||||
|
- Channel-based waiting mechanism for synchronization
|
||||||
|
|
||||||
|
2. **InteractiveCommandExecutor** (`local/utl/command/interactive_executor.go`)
|
||||||
|
- Monitors SteamCMD output for 2FA prompts
|
||||||
|
- Creates 2FA requests when prompts are detected
|
||||||
|
- Waits for user confirmation before proceeding
|
||||||
|
|
||||||
|
3. **Steam2FAController** (`local/controller/steam_2fa.go`)
|
||||||
|
- REST API endpoints for 2FA management
|
||||||
|
- Handles frontend requests to complete/cancel 2FA
|
||||||
|
|
||||||
|
4. **Updated SteamService** (`local/service/steam_service.go`)
|
||||||
|
- Uses InteractiveCommandExecutor for SteamCMD operations
|
||||||
|
- Passes server context to 2FA requests
|
||||||
|
|
||||||
|
### Frontend Components
|
||||||
|
|
||||||
|
1. **Steam2FA Store** (`src/stores/steam2fa.ts`)
|
||||||
|
- Svelte store for managing 2FA state
|
||||||
|
- Automatic polling for pending requests
|
||||||
|
- API communication methods
|
||||||
|
|
||||||
|
2. **Steam2FANotification Component** (`src/components/Steam2FANotification.svelte`)
|
||||||
|
- Modal UI for 2FA confirmation
|
||||||
|
- Automatic display when requests are pending
|
||||||
|
- User interaction handling
|
||||||
|
|
||||||
|
3. **Type Definitions** (`src/models/steam2fa.ts`)
|
||||||
|
- TypeScript interfaces for 2FA data structures
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### GET /v1/steam2fa/pending
|
||||||
|
Returns all pending 2FA requests.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "uuid-string",
|
||||||
|
"status": "pending",
|
||||||
|
"message": "Steam Guard prompt message",
|
||||||
|
"requestTime": "2024-01-01T12:00:00Z",
|
||||||
|
"serverId": "server-uuid"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /v1/steam2fa/{id}
|
||||||
|
Returns a specific 2FA request by ID.
|
||||||
|
|
||||||
|
### POST /v1/steam2fa/{id}/complete
|
||||||
|
Marks a 2FA request as completed, allowing SteamCMD to proceed.
|
||||||
|
|
||||||
|
### POST /v1/steam2fa/{id}/cancel
|
||||||
|
Cancels a 2FA request, causing the SteamCMD operation to fail.
|
||||||
|
|
||||||
|
## Flow Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
SteamCMD Operation
|
||||||
|
↓
|
||||||
|
InteractiveCommandExecutor monitors output
|
||||||
|
↓
|
||||||
|
2FA prompt detected
|
||||||
|
↓
|
||||||
|
Steam2FARequest created
|
||||||
|
↓
|
||||||
|
Frontend polls and detects request
|
||||||
|
↓
|
||||||
|
Modal appears for user
|
||||||
|
↓
|
||||||
|
User confirms in Steam Mobile App
|
||||||
|
↓
|
||||||
|
User clicks "I've Confirmed"
|
||||||
|
↓
|
||||||
|
API call to complete request
|
||||||
|
↓
|
||||||
|
SteamCMD operation continues
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Backend Configuration
|
||||||
|
|
||||||
|
The system uses existing configuration patterns. No additional environment variables are required.
|
||||||
|
|
||||||
|
### Frontend Configuration
|
||||||
|
|
||||||
|
The API base URL is automatically configured as `/v1` to match the backend prefix.
|
||||||
|
|
||||||
|
Polling interval is set to 5 seconds by default and can be modified in `steam2fa.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const POLLING_INTERVAL = 5000; // milliseconds
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **Authentication Required**: All 2FA endpoints require user authentication
|
||||||
|
2. **Permission-Based Access**: Uses existing `ServerView` and `ServerUpdate` permissions
|
||||||
|
3. **Request Cleanup**: Automatic cleanup of old requests (30 minutes) prevents memory leaks
|
||||||
|
4. **No Sensitive Data**: No Steam credentials are exposed through the 2FA system
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Backend Error Handling
|
||||||
|
- Timeouts after 5 minutes if no user response
|
||||||
|
- Proper error propagation to calling services
|
||||||
|
- Comprehensive logging for debugging
|
||||||
|
|
||||||
|
### Frontend Error Handling
|
||||||
|
- Network error handling with user feedback
|
||||||
|
- Automatic retry mechanisms
|
||||||
|
- Graceful degradation when API is unavailable
|
||||||
|
|
||||||
|
## Usage Instructions
|
||||||
|
|
||||||
|
### For Developers
|
||||||
|
|
||||||
|
1. **Adding New 2FA Prompts**: Extend the `is2FAPrompt` function in `interactive_executor.go`
|
||||||
|
2. **Customizing Timeouts**: Modify the timeout duration in `handle2FAPrompt`
|
||||||
|
3. **UI Customization**: Modify the `Steam2FANotification.svelte` component
|
||||||
|
|
||||||
|
### For Users
|
||||||
|
|
||||||
|
1. When creating or updating a server, watch for the 2FA notification
|
||||||
|
2. Check your Steam Mobile App when prompted
|
||||||
|
3. Confirm the login request in the Steam app
|
||||||
|
4. Click "I've Confirmed" in the web interface
|
||||||
|
5. The server operation will continue automatically
|
||||||
|
|
||||||
|
## Monitoring and Debugging
|
||||||
|
|
||||||
|
### Backend Logs
|
||||||
|
The system logs important events:
|
||||||
|
- 2FA prompt detection
|
||||||
|
- Request creation and completion
|
||||||
|
- Timeout events
|
||||||
|
- Error conditions
|
||||||
|
|
||||||
|
Search for log entries containing:
|
||||||
|
- `2FA prompt detected`
|
||||||
|
- `Created 2FA request`
|
||||||
|
- `2FA completed successfully`
|
||||||
|
- `2FA completion failed`
|
||||||
|
|
||||||
|
### Frontend Debugging
|
||||||
|
The Steam2FA store provides debugging information:
|
||||||
|
- `$steam2fa.error` - Current error state
|
||||||
|
- `$steam2fa.isLoading` - Loading state
|
||||||
|
- `$steam2fa.lastChecked` - Last polling timestamp
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
1. **Polling Frequency**: 5-second polling provides good responsiveness without excessive load
|
||||||
|
2. **Request Cleanup**: Automatic cleanup prevents memory accumulation
|
||||||
|
3. **Efficient UI Updates**: Reactive Svelte stores minimize unnecessary re-renders
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
1. **Single User Sessions**: Currently designed for single-user scenarios
|
||||||
|
2. **Steam Mobile App Required**: Users must have Steam Mobile App installed
|
||||||
|
3. **Manual Confirmation**: No automatic 2FA code input support
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **WebSocket Support**: Real-time communication instead of polling
|
||||||
|
2. **Multiple User Support**: Handle multiple simultaneous 2FA requests
|
||||||
|
3. **Enhanced Prompt Detection**: More sophisticated Steam output parsing
|
||||||
|
4. **Notification System**: Browser notifications for 2FA requests
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Manual Testing
|
||||||
|
1. Create a new server to trigger SteamCMD
|
||||||
|
2. Ensure Steam account has 2FA enabled
|
||||||
|
3. Verify modal appears when 2FA is required
|
||||||
|
4. Test both "confirm" and "cancel" workflows
|
||||||
|
|
||||||
|
### Automated Testing
|
||||||
|
The system includes comprehensive error handling but manual testing is recommended for 2FA workflows due to the interactive nature.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Modal doesn't appear**
|
||||||
|
- Check browser console for errors
|
||||||
|
- Verify API connectivity
|
||||||
|
- Ensure user has proper permissions
|
||||||
|
|
||||||
|
2. **SteamCMD hangs**
|
||||||
|
- Check if 2FA request was created (backend logs)
|
||||||
|
- Verify Steam Mobile App connectivity
|
||||||
|
- Check for timeout errors
|
||||||
|
|
||||||
|
3. **API errors**
|
||||||
|
- Verify user authentication
|
||||||
|
- Check server permissions
|
||||||
|
- Review backend error logs
|
||||||
|
|
||||||
|
### Debug Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check backend logs for 2FA events
|
||||||
|
grep -i "2fa" logs/app.log
|
||||||
|
|
||||||
|
# Monitor API requests
|
||||||
|
tail -f logs/app.log | grep "steam2fa"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Version History
|
||||||
|
|
||||||
|
- **v1.0.0**: Initial implementation with polling-based frontend and REST API
|
||||||
|
- Added comprehensive error handling and logging
|
||||||
|
- Implemented automatic request cleanup
|
||||||
|
- Added responsive UI components
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
When contributing to the 2FA system:
|
||||||
|
|
||||||
|
1. Follow existing error handling patterns
|
||||||
|
2. Add comprehensive logging for new features
|
||||||
|
3. Update this documentation for any API changes
|
||||||
|
4. Test with actual Steam 2FA scenarios
|
||||||
|
5. Consider security implications of any changes
|
||||||
@@ -31,6 +31,7 @@ func Init(di *dig.Container, app *fiber.App) {
|
|||||||
StateHistory: serverIdGroup.Group("/state-history"),
|
StateHistory: serverIdGroup.Group("/state-history"),
|
||||||
Membership: groups.Group("/membership"),
|
Membership: groups.Group("/membership"),
|
||||||
System: groups.Group("/system"),
|
System: groups.Group("/system"),
|
||||||
|
Steam2FA: groups.Group("/steam2fa"),
|
||||||
}
|
}
|
||||||
|
|
||||||
accessKeyMiddleware := middleware.NewAccessKeyMiddleware()
|
accessKeyMiddleware := middleware.NewAccessKeyMiddleware()
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func NewConfigController(as *service.ConfigService, routeGroups *common.RouteGro
|
|||||||
// @Failure 404 {object} error_handler.ErrorResponse "Server or config file not found"
|
// @Failure 404 {object} error_handler.ErrorResponse "Server or config file not found"
|
||||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /v1/server/{id}/config/{file} [put]
|
// @Router /server/{id}/config/{file} [put]
|
||||||
func (ac *ConfigController) UpdateConfig(c *fiber.Ctx) error {
|
func (ac *ConfigController) UpdateConfig(c *fiber.Ctx) error {
|
||||||
restart := c.QueryBool("restart")
|
restart := c.QueryBool("restart")
|
||||||
serverID := c.Params("id")
|
serverID := c.Params("id")
|
||||||
@@ -106,7 +106,7 @@ func (ac *ConfigController) UpdateConfig(c *fiber.Ctx) error {
|
|||||||
// @Failure 404 {object} error_handler.ErrorResponse "Server or config file not found"
|
// @Failure 404 {object} error_handler.ErrorResponse "Server or config file not found"
|
||||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /v1/server/{id}/config/{file} [get]
|
// @Router /server/{id}/config/{file} [get]
|
||||||
func (ac *ConfigController) GetConfig(c *fiber.Ctx) error {
|
func (ac *ConfigController) GetConfig(c *fiber.Ctx) error {
|
||||||
Model, err := ac.service.GetConfig(c)
|
Model, err := ac.service.GetConfig(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -130,7 +130,7 @@ func (ac *ConfigController) GetConfig(c *fiber.Ctx) error {
|
|||||||
// @Failure 404 {object} error_handler.ErrorResponse "Server not found"
|
// @Failure 404 {object} error_handler.ErrorResponse "Server not found"
|
||||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /v1/server/{id}/config [get]
|
// @Router /server/{id}/config [get]
|
||||||
func (ac *ConfigController) GetConfigs(c *fiber.Ctx) error {
|
func (ac *ConfigController) GetConfigs(c *fiber.Ctx) error {
|
||||||
Model, err := ac.service.GetConfigs(c)
|
Model, err := ac.service.GetConfigs(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ func InitializeControllers(c *dig.Container) {
|
|||||||
logging.Panic("unable to initialize auth middleware")
|
logging.Panic("unable to initialize auth middleware")
|
||||||
}
|
}
|
||||||
|
|
||||||
err := c.Invoke(NewServiceControlController)
|
err := c.Invoke(NewSystemController)
|
||||||
|
if err != nil {
|
||||||
|
logging.Panic("unable to initialize system controller")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = c.Invoke(NewServiceControlController)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Panic("unable to initialize service control controller")
|
logging.Panic("unable to initialize service control controller")
|
||||||
}
|
}
|
||||||
@@ -49,4 +54,9 @@ func InitializeControllers(c *dig.Container) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Panic("unable to initialize membership controller")
|
logging.Panic("unable to initialize membership controller")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = c.Invoke(NewSteam2FAController)
|
||||||
|
if err != nil {
|
||||||
|
logging.Panic("unable to initialize steam 2fa controller")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func NewLookupController(as *service.LookupService, routeGroups *common.RouteGro
|
|||||||
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /v1/lookup/tracks [get]
|
// @Router /lookup/tracks [get]
|
||||||
func (ac *LookupController) GetTracks(c *fiber.Ctx) error {
|
func (ac *LookupController) GetTracks(c *fiber.Ctx) error {
|
||||||
result, err := ac.service.GetTracks(c)
|
result, err := ac.service.GetTracks(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -66,7 +66,7 @@ func (ac *LookupController) GetTracks(c *fiber.Ctx) error {
|
|||||||
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /v1/lookup/car-models [get]
|
// @Router /lookup/car-models [get]
|
||||||
func (ac *LookupController) GetCarModels(c *fiber.Ctx) error {
|
func (ac *LookupController) GetCarModels(c *fiber.Ctx) error {
|
||||||
result, err := ac.service.GetCarModels(c)
|
result, err := ac.service.GetCarModels(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -86,7 +86,7 @@ func (ac *LookupController) GetCarModels(c *fiber.Ctx) error {
|
|||||||
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /v1/lookup/driver-categories [get]
|
// @Router /lookup/driver-categories [get]
|
||||||
func (ac *LookupController) GetDriverCategories(c *fiber.Ctx) error {
|
func (ac *LookupController) GetDriverCategories(c *fiber.Ctx) error {
|
||||||
result, err := ac.service.GetDriverCategories(c)
|
result, err := ac.service.GetDriverCategories(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -106,7 +106,7 @@ func (ac *LookupController) GetDriverCategories(c *fiber.Ctx) error {
|
|||||||
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /v1/lookup/cup-categories [get]
|
// @Router /lookup/cup-categories [get]
|
||||||
func (ac *LookupController) GetCupCategories(c *fiber.Ctx) error {
|
func (ac *LookupController) GetCupCategories(c *fiber.Ctx) error {
|
||||||
result, err := ac.service.GetCupCategories(c)
|
result, err := ac.service.GetCupCategories(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -126,7 +126,7 @@ func (ac *LookupController) GetCupCategories(c *fiber.Ctx) error {
|
|||||||
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /v1/lookup/session-types [get]
|
// @Router /lookup/session-types [get]
|
||||||
func (ac *LookupController) GetSessionTypes(c *fiber.Ctx) error {
|
func (ac *LookupController) GetSessionTypes(c *fiber.Ctx) error {
|
||||||
result, err := ac.service.GetSessionTypes(c)
|
result, err := ac.service.GetSessionTypes(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ func NewMembershipController(service *service.MembershipService, auth *middlewar
|
|||||||
}
|
}
|
||||||
|
|
||||||
routeGroups.Auth.Post("/login", mc.Login)
|
routeGroups.Auth.Post("/login", mc.Login)
|
||||||
|
routeGroups.Auth.Post("/open-token", mc.auth.Authenticate, mc.GenerateOpenToken)
|
||||||
|
|
||||||
usersGroup := routeGroups.Membership
|
usersGroup := routeGroups.Membership
|
||||||
usersGroup.Use(mc.auth.Authenticate)
|
usersGroup.Use(mc.auth.Authenticate)
|
||||||
@@ -82,6 +83,26 @@ func (c *MembershipController) Login(ctx *fiber.Ctx) error {
|
|||||||
return ctx.JSON(fiber.Map{"token": token})
|
return ctx.JSON(fiber.Map{"token": token})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GenerateOpenToken generates an open token for a user.
|
||||||
|
// @Summary Generate an open token
|
||||||
|
// @Description Generate an open token for a user
|
||||||
|
// @Tags Authentication
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} object{token=string} "JWT token"
|
||||||
|
// @Failure 400 {object} error_handler.ErrorResponse "Invalid request body"
|
||||||
|
// @Failure 401 {object} error_handler.ErrorResponse "Invalid credentials"
|
||||||
|
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||||
|
// @Router /auth/open-token [post]
|
||||||
|
func (c *MembershipController) GenerateOpenToken(ctx *fiber.Ctx) error {
|
||||||
|
token, err := c.service.GenerateOpenToken(ctx.UserContext(), ctx.Locals("userID").(string))
|
||||||
|
if err != nil {
|
||||||
|
return c.errorHandler.HandleAuthError(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.JSON(fiber.Map{"token": token})
|
||||||
|
}
|
||||||
|
|
||||||
// CreateUser creates a new user.
|
// CreateUser creates a new user.
|
||||||
// @Summary Create a new user
|
// @Summary Create a new user
|
||||||
// @Description Create a new user account with specified role
|
// @Description Create a new user account with specified role
|
||||||
@@ -139,6 +160,18 @@ func (mc *MembershipController) ListUsers(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetUser gets a single user by ID.
|
// GetUser gets a single user by ID.
|
||||||
|
// @Summary Get user by ID
|
||||||
|
// @Description Get detailed information about a specific user
|
||||||
|
// @Tags User Management
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "User ID (UUID format)"
|
||||||
|
// @Success 200 {object} model.User "User details"
|
||||||
|
// @Failure 400 {object} error_handler.ErrorResponse "Invalid user ID format"
|
||||||
|
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||||
|
// @Failure 404 {object} error_handler.ErrorResponse "User not found"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /membership/{id} [get]
|
||||||
func (mc *MembershipController) GetUser(c *fiber.Ctx) error {
|
func (mc *MembershipController) GetUser(c *fiber.Ctx) error {
|
||||||
id, err := uuid.Parse(c.Params("id"))
|
id, err := uuid.Parse(c.Params("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -154,6 +187,16 @@ func (mc *MembershipController) GetUser(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetMe returns the currently authenticated user's details.
|
// GetMe returns the currently authenticated user's details.
|
||||||
|
// @Summary Get current user details
|
||||||
|
// @Description Get details of the currently authenticated user
|
||||||
|
// @Tags Authentication
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} model.User "Current user details"
|
||||||
|
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||||
|
// @Failure 404 {object} error_handler.ErrorResponse "User not found"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /auth/me [get]
|
||||||
func (mc *MembershipController) GetMe(c *fiber.Ctx) error {
|
func (mc *MembershipController) GetMe(c *fiber.Ctx) error {
|
||||||
userID, ok := c.Locals("userID").(string)
|
userID, ok := c.Locals("userID").(string)
|
||||||
if !ok || userID == "" {
|
if !ok || userID == "" {
|
||||||
@@ -172,6 +215,19 @@ func (mc *MembershipController) GetMe(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeleteUser deletes a user.
|
// DeleteUser deletes a user.
|
||||||
|
// @Summary Delete user
|
||||||
|
// @Description Delete a specific user by ID
|
||||||
|
// @Tags User Management
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "User ID (UUID format)"
|
||||||
|
// @Success 204 "User successfully deleted"
|
||||||
|
// @Failure 400 {object} error_handler.ErrorResponse "Invalid user ID format"
|
||||||
|
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||||
|
// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions"
|
||||||
|
// @Failure 404 {object} error_handler.ErrorResponse "User not found"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /membership/{id} [delete]
|
||||||
func (mc *MembershipController) DeleteUser(c *fiber.Ctx) error {
|
func (mc *MembershipController) DeleteUser(c *fiber.Ctx) error {
|
||||||
id, err := uuid.Parse(c.Params("id"))
|
id, err := uuid.Parse(c.Params("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -187,6 +243,20 @@ func (mc *MembershipController) DeleteUser(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpdateUser updates a user.
|
// UpdateUser updates a user.
|
||||||
|
// @Summary Update user
|
||||||
|
// @Description Update user details by ID
|
||||||
|
// @Tags User Management
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "User ID (UUID format)"
|
||||||
|
// @Param user body service.UpdateUserRequest true "Updated user details"
|
||||||
|
// @Success 200 {object} model.User "Updated user details"
|
||||||
|
// @Failure 400 {object} error_handler.ErrorResponse "Invalid request body or ID format"
|
||||||
|
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||||
|
// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions"
|
||||||
|
// @Failure 404 {object} error_handler.ErrorResponse "User not found"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /membership/{id} [put]
|
||||||
func (mc *MembershipController) UpdateUser(c *fiber.Ctx) error {
|
func (mc *MembershipController) UpdateUser(c *fiber.Ctx) error {
|
||||||
id, err := uuid.Parse(c.Params("id"))
|
id, err := uuid.Parse(c.Params("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -207,6 +277,17 @@ func (mc *MembershipController) UpdateUser(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetRoles returns all available roles.
|
// GetRoles returns all available roles.
|
||||||
|
// @Summary Get all roles
|
||||||
|
// @Description Get a list of all available user roles
|
||||||
|
// @Tags User Management
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} model.Role "List of roles"
|
||||||
|
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||||
|
// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions"
|
||||||
|
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /membership/roles [get]
|
||||||
func (mc *MembershipController) GetRoles(c *fiber.Ctx) error {
|
func (mc *MembershipController) GetRoles(c *fiber.Ctx) error {
|
||||||
roles, err := mc.service.GetAllRoles(c.UserContext())
|
roles, err := mc.service.GetAllRoles(c.UserContext())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func NewServerController(ss *service.ServerService, routeGroups *common.RouteGro
|
|||||||
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /server [get]
|
// @Router /api/server [get]
|
||||||
func (ac *ServerController) GetAllApi(c *fiber.Ctx) error {
|
func (ac *ServerController) GetAllApi(c *fiber.Ctx) error {
|
||||||
var filter model.ServerFilter
|
var filter model.ServerFilter
|
||||||
if err := common.ParseQueryFilter(c, &filter); err != nil {
|
if err := common.ParseQueryFilter(c, &filter); err != nil {
|
||||||
@@ -79,7 +79,7 @@ func (ac *ServerController) GetAllApi(c *fiber.Ctx) error {
|
|||||||
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /v1/server [get]
|
// @Router /server [get]
|
||||||
func (ac *ServerController) GetAll(c *fiber.Ctx) error {
|
func (ac *ServerController) GetAll(c *fiber.Ctx) error {
|
||||||
var filter model.ServerFilter
|
var filter model.ServerFilter
|
||||||
if err := common.ParseQueryFilter(c, &filter); err != nil {
|
if err := common.ParseQueryFilter(c, &filter); err != nil {
|
||||||
@@ -105,7 +105,7 @@ func (ac *ServerController) GetAll(c *fiber.Ctx) error {
|
|||||||
// @Failure 404 {object} error_handler.ErrorResponse "Server not found"
|
// @Failure 404 {object} error_handler.ErrorResponse "Server not found"
|
||||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /v1/server/{id} [get]
|
// @Router /server/{id} [get]
|
||||||
func (ac *ServerController) GetById(c *fiber.Ctx) error {
|
func (ac *ServerController) GetById(c *fiber.Ctx) error {
|
||||||
serverIDStr := c.Params("id")
|
serverIDStr := c.Params("id")
|
||||||
serverID, err := uuid.Parse(serverIDStr)
|
serverID, err := uuid.Parse(serverIDStr)
|
||||||
@@ -133,7 +133,7 @@ func (ac *ServerController) GetById(c *fiber.Ctx) error {
|
|||||||
// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions"
|
// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions"
|
||||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /v1/server [post]
|
// @Router /server [post]
|
||||||
func (ac *ServerController) CreateServer(c *fiber.Ctx) error {
|
func (ac *ServerController) CreateServer(c *fiber.Ctx) error {
|
||||||
server := new(model.Server)
|
server := new(model.Server)
|
||||||
if err := c.BodyParser(server); err != nil {
|
if err := c.BodyParser(server); err != nil {
|
||||||
@@ -161,7 +161,7 @@ func (ac *ServerController) CreateServer(c *fiber.Ctx) error {
|
|||||||
// @Failure 404 {object} error_handler.ErrorResponse "Server not found"
|
// @Failure 404 {object} error_handler.ErrorResponse "Server not found"
|
||||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /v1/server/{id} [put]
|
// @Router /server/{id} [put]
|
||||||
func (ac *ServerController) UpdateServer(c *fiber.Ctx) error {
|
func (ac *ServerController) UpdateServer(c *fiber.Ctx) error {
|
||||||
serverIDStr := c.Params("id")
|
serverIDStr := c.Params("id")
|
||||||
serverID, err := uuid.Parse(serverIDStr)
|
serverID, err := uuid.Parse(serverIDStr)
|
||||||
@@ -181,7 +181,21 @@ func (ac *ServerController) UpdateServer(c *fiber.Ctx) error {
|
|||||||
return c.JSON(server)
|
return c.JSON(server)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteServer deletes a server
|
// DeleteServer deletes an existing server
|
||||||
|
// @Summary Delete an ACC server
|
||||||
|
// @Description Delete an existing ACC server
|
||||||
|
// @Tags Server
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Server ID (UUID format)"
|
||||||
|
// @Success 200 {object} object "Deleted server details"
|
||||||
|
// @Failure 400 {object} error_handler.ErrorResponse "Invalid server data or ID"
|
||||||
|
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||||
|
// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions"
|
||||||
|
// @Failure 404 {object} error_handler.ErrorResponse "Server not found"
|
||||||
|
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /server/{id} [delete]
|
||||||
func (ac *ServerController) DeleteServer(c *fiber.Ctx) error {
|
func (ac *ServerController) DeleteServer(c *fiber.Ctx) error {
|
||||||
serverIDStr := c.Params("id")
|
serverIDStr := c.Params("id")
|
||||||
serverID, err := uuid.Parse(serverIDStr)
|
serverID, err := uuid.Parse(serverIDStr)
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func NewServiceControlController(as *service.ServiceControlService, routeGroups
|
|||||||
// @Failure 404 {object} error_handler.ErrorResponse "Service not found"
|
// @Failure 404 {object} error_handler.ErrorResponse "Service not found"
|
||||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /v1/service-control/{service} [get]
|
// @Router /server/{id}/service/{service} [get]
|
||||||
func (ac *ServiceControlController) getStatus(c *fiber.Ctx) error {
|
func (ac *ServiceControlController) getStatus(c *fiber.Ctx) error {
|
||||||
id := c.Params("id")
|
id := c.Params("id")
|
||||||
c.Locals("serverId", id)
|
c.Locals("serverId", id)
|
||||||
@@ -78,7 +78,7 @@ func (ac *ServiceControlController) getStatus(c *fiber.Ctx) error {
|
|||||||
// @Failure 409 {object} error_handler.ErrorResponse "Service already running"
|
// @Failure 409 {object} error_handler.ErrorResponse "Service already running"
|
||||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /v1/service-control/start [post]
|
// @Router /server/{id}/service/start [post]
|
||||||
func (ac *ServiceControlController) startServer(c *fiber.Ctx) error {
|
func (ac *ServiceControlController) startServer(c *fiber.Ctx) error {
|
||||||
id := c.Params("id")
|
id := c.Params("id")
|
||||||
c.Locals("serverId", id)
|
c.Locals("serverId", id)
|
||||||
@@ -105,7 +105,7 @@ func (ac *ServiceControlController) startServer(c *fiber.Ctx) error {
|
|||||||
// @Failure 409 {object} error_handler.ErrorResponse "Service already stopped"
|
// @Failure 409 {object} error_handler.ErrorResponse "Service already stopped"
|
||||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /v1/service-control/stop [post]
|
// @Router /server/{id}/service/stop [post]
|
||||||
func (ac *ServiceControlController) stopServer(c *fiber.Ctx) error {
|
func (ac *ServiceControlController) stopServer(c *fiber.Ctx) error {
|
||||||
id := c.Params("id")
|
id := c.Params("id")
|
||||||
c.Locals("serverId", id)
|
c.Locals("serverId", id)
|
||||||
@@ -131,7 +131,7 @@ func (ac *ServiceControlController) stopServer(c *fiber.Ctx) error {
|
|||||||
// @Failure 404 {object} error_handler.ErrorResponse "Service not found"
|
// @Failure 404 {object} error_handler.ErrorResponse "Service not found"
|
||||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /v1/service-control/restart [post]
|
// @Router /server/{id}/service/restart [post]
|
||||||
func (ac *ServiceControlController) restartServer(c *fiber.Ctx) error {
|
func (ac *ServiceControlController) restartServer(c *fiber.Ctx) error {
|
||||||
id := c.Params("id")
|
id := c.Params("id")
|
||||||
c.Locals("serverId", id)
|
c.Locals("serverId", id)
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func NewStateHistoryController(as *service.StateHistoryService, routeGroups *com
|
|||||||
// @Description Return StateHistorys
|
// @Description Return StateHistorys
|
||||||
// @Tags StateHistory
|
// @Tags StateHistory
|
||||||
// @Success 200 {array} string
|
// @Success 200 {array} string
|
||||||
// @Router /v1/state-history [get]
|
// @Router /state-history [get]
|
||||||
func (ac *StateHistoryController) GetAll(c *fiber.Ctx) error {
|
func (ac *StateHistoryController) GetAll(c *fiber.Ctx) error {
|
||||||
var filter model.StateHistoryFilter
|
var filter model.StateHistoryFilter
|
||||||
if err := common.ParseQueryFilter(c, &filter); err != nil {
|
if err := common.ParseQueryFilter(c, &filter); err != nil {
|
||||||
@@ -63,7 +63,7 @@ func (ac *StateHistoryController) GetAll(c *fiber.Ctx) error {
|
|||||||
// @Description Return StateHistorys
|
// @Description Return StateHistorys
|
||||||
// @Tags StateHistory
|
// @Tags StateHistory
|
||||||
// @Success 200 {array} string
|
// @Success 200 {array} string
|
||||||
// @Router /v1/state-history/statistics [get]
|
// @Router /state-history/statistics [get]
|
||||||
func (ac *StateHistoryController) GetStatistics(c *fiber.Ctx) error {
|
func (ac *StateHistoryController) GetStatistics(c *fiber.Ctx) error {
|
||||||
var filter model.StateHistoryFilter
|
var filter model.StateHistoryFilter
|
||||||
if err := common.ParseQueryFilter(c, &filter); err != nil {
|
if err := common.ParseQueryFilter(c, &filter); err != nil {
|
||||||
|
|||||||
139
local/controller/steam_2fa.go
Normal file
139
local/controller/steam_2fa.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"acc-server-manager/local/middleware"
|
||||||
|
"acc-server-manager/local/model"
|
||||||
|
"acc-server-manager/local/utl/common"
|
||||||
|
"acc-server-manager/local/utl/error_handler"
|
||||||
|
"acc-server-manager/local/utl/jwt"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Steam2FAController struct {
|
||||||
|
tfaManager *model.Steam2FAManager
|
||||||
|
errorHandler *error_handler.ControllerErrorHandler
|
||||||
|
jwtHandler *jwt.OpenJWTHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSteam2FAController(tfaManager *model.Steam2FAManager, routeGroups *common.RouteGroups, auth *middleware.AuthMiddleware, jwtHandler *jwt.OpenJWTHandler) *Steam2FAController {
|
||||||
|
controller := &Steam2FAController{
|
||||||
|
tfaManager: tfaManager,
|
||||||
|
errorHandler: error_handler.NewControllerErrorHandler(),
|
||||||
|
jwtHandler: jwtHandler,
|
||||||
|
}
|
||||||
|
|
||||||
|
steam2faRoutes := routeGroups.Steam2FA
|
||||||
|
steam2faRoutes.Use(auth.AuthenticateOpen)
|
||||||
|
|
||||||
|
// Define routes
|
||||||
|
steam2faRoutes.Get("/pending", auth.HasPermission(model.ServerView), controller.GetPendingRequests)
|
||||||
|
steam2faRoutes.Get("/:id", auth.HasPermission(model.ServerView), controller.GetRequest)
|
||||||
|
steam2faRoutes.Post("/:id/complete", auth.HasPermission(model.ServerUpdate), controller.CompleteRequest)
|
||||||
|
steam2faRoutes.Post("/:id/cancel", auth.HasPermission(model.ServerUpdate), controller.CancelRequest)
|
||||||
|
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPendingRequests gets all pending 2FA requests
|
||||||
|
//
|
||||||
|
// @Summary Get pending 2FA requests
|
||||||
|
// @Description Get all pending Steam 2FA authentication requests
|
||||||
|
// @Tags Steam 2FA
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} model.Steam2FARequest
|
||||||
|
// @Failure 500 {object} error_handler.ErrorResponse
|
||||||
|
// @Router /steam2fa/pending [get]
|
||||||
|
func (c *Steam2FAController) GetPendingRequests(ctx *fiber.Ctx) error {
|
||||||
|
requests := c.tfaManager.GetPendingRequests()
|
||||||
|
return ctx.JSON(requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRequest gets a specific 2FA request by ID
|
||||||
|
//
|
||||||
|
// @Summary Get 2FA request
|
||||||
|
// @Description Get a specific Steam 2FA authentication request by ID
|
||||||
|
// @Tags Steam 2FA
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "2FA Request ID"
|
||||||
|
// @Success 200 {object} model.Steam2FARequest
|
||||||
|
// @Failure 404 {object} error_handler.ErrorResponse
|
||||||
|
// @Failure 500 {object} error_handler.ErrorResponse
|
||||||
|
// @Router /steam2fa/{id} [get]
|
||||||
|
func (c *Steam2FAController) GetRequest(ctx *fiber.Ctx) error {
|
||||||
|
id := ctx.Params("id")
|
||||||
|
if id == "" {
|
||||||
|
return c.errorHandler.HandleError(ctx, fiber.ErrBadRequest, fiber.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
request, exists := c.tfaManager.GetRequest(id)
|
||||||
|
if !exists {
|
||||||
|
return c.errorHandler.HandleNotFoundError(ctx, "2FA request")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.JSON(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteRequest marks a 2FA request as completed
|
||||||
|
//
|
||||||
|
// @Summary Complete 2FA request
|
||||||
|
// @Description Mark a Steam 2FA authentication request as completed
|
||||||
|
// @Tags Steam 2FA
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "2FA Request ID"
|
||||||
|
// @Success 200 {object} model.Steam2FARequest
|
||||||
|
// @Failure 400 {object} error_handler.ErrorResponse
|
||||||
|
// @Failure 404 {object} error_handler.ErrorResponse
|
||||||
|
// @Failure 500 {object} error_handler.ErrorResponse
|
||||||
|
// @Router /steam2fa/{id}/complete [post]
|
||||||
|
func (c *Steam2FAController) CompleteRequest(ctx *fiber.Ctx) error {
|
||||||
|
id := ctx.Params("id")
|
||||||
|
if id == "" {
|
||||||
|
return c.errorHandler.HandleError(ctx, fiber.ErrBadRequest, fiber.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.tfaManager.CompleteRequest(id); err != nil {
|
||||||
|
return c.errorHandler.HandleError(ctx, err, fiber.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
request, exists := c.tfaManager.GetRequest(id)
|
||||||
|
if !exists {
|
||||||
|
return c.errorHandler.HandleNotFoundError(ctx, "2FA request")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.JSON(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelRequest cancels a 2FA request
|
||||||
|
//
|
||||||
|
// @Summary Cancel 2FA request
|
||||||
|
// @Description Cancel a Steam 2FA authentication request
|
||||||
|
// @Tags Steam 2FA
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "2FA Request ID"
|
||||||
|
// @Success 200 {object} model.Steam2FARequest
|
||||||
|
// @Failure 400 {object} error_handler.ErrorResponse
|
||||||
|
// @Failure 404 {object} error_handler.ErrorResponse
|
||||||
|
// @Failure 500 {object} error_handler.ErrorResponse
|
||||||
|
// @Router /steam2fa/{id}/cancel [post]
|
||||||
|
func (c *Steam2FAController) CancelRequest(ctx *fiber.Ctx) error {
|
||||||
|
id := ctx.Params("id")
|
||||||
|
if id == "" {
|
||||||
|
return c.errorHandler.HandleError(ctx, fiber.ErrBadRequest, fiber.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.tfaManager.ErrorRequest(id, "cancelled by user"); err != nil {
|
||||||
|
return c.errorHandler.HandleError(ctx, err, fiber.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
request, exists := c.tfaManager.GetRequest(id)
|
||||||
|
if !exists {
|
||||||
|
return c.errorHandler.HandleNotFoundError(ctx, "2FA request")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.JSON(request)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"acc-server-manager/local/utl/common"
|
"acc-server-manager/local/utl/common"
|
||||||
|
"acc-server-manager/local/utl/configs"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
@@ -30,9 +31,9 @@ func NewSystemController(routeGroups *common.RouteGroups) *SystemController {
|
|||||||
//
|
//
|
||||||
// @Summary Return service control status
|
// @Summary Return service control status
|
||||||
// @Description Return service control status
|
// @Description Return service control status
|
||||||
// @Tags service-control
|
// @Tags system
|
||||||
// @Success 200 {array} string
|
// @Success 200 {array} string
|
||||||
// @Router /v1/service-control [get]
|
// @Router /system/health [get]
|
||||||
func (ac *SystemController) getFirst(c *fiber.Ctx) error {
|
func (ac *SystemController) getFirst(c *fiber.Ctx) error {
|
||||||
return c.SendStatus(fiber.StatusOK)
|
return c.SendString(configs.Version)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,14 +30,18 @@ type AuthMiddleware struct {
|
|||||||
membershipService *service.MembershipService
|
membershipService *service.MembershipService
|
||||||
cache *cache.InMemoryCache
|
cache *cache.InMemoryCache
|
||||||
securityMW *security.SecurityMiddleware
|
securityMW *security.SecurityMiddleware
|
||||||
|
jwtHandler *jwt.JWTHandler
|
||||||
|
openJWTHandler *jwt.OpenJWTHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthMiddleware creates a new AuthMiddleware.
|
// NewAuthMiddleware creates a new AuthMiddleware.
|
||||||
func NewAuthMiddleware(ms *service.MembershipService, cache *cache.InMemoryCache) *AuthMiddleware {
|
func NewAuthMiddleware(ms *service.MembershipService, cache *cache.InMemoryCache, jwtHandler *jwt.JWTHandler, openJWTHandler *jwt.OpenJWTHandler) *AuthMiddleware {
|
||||||
auth := &AuthMiddleware{
|
auth := &AuthMiddleware{
|
||||||
membershipService: ms,
|
membershipService: ms,
|
||||||
cache: cache,
|
cache: cache,
|
||||||
securityMW: security.NewSecurityMiddleware(),
|
securityMW: security.NewSecurityMiddleware(),
|
||||||
|
jwtHandler: jwtHandler,
|
||||||
|
openJWTHandler: openJWTHandler,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up bidirectional relationship for cache invalidation
|
// Set up bidirectional relationship for cache invalidation
|
||||||
@@ -46,13 +50,30 @@ func NewAuthMiddleware(ms *service.MembershipService, cache *cache.InMemoryCache
|
|||||||
return auth
|
return auth
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authenticate is a middleware for JWT authentication with enhanced security.
|
||||||
|
func (m *AuthMiddleware) AuthenticateOpen(ctx *fiber.Ctx) error {
|
||||||
|
return m.AuthenticateWithHandler(m.openJWTHandler.JWTHandler, true, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// Authenticate is a middleware for JWT authentication with enhanced security.
|
// Authenticate is a middleware for JWT authentication with enhanced security.
|
||||||
func (m *AuthMiddleware) Authenticate(ctx *fiber.Ctx) error {
|
func (m *AuthMiddleware) Authenticate(ctx *fiber.Ctx) error {
|
||||||
|
return m.AuthenticateWithHandler(m.jwtHandler, false, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AuthMiddleware) AuthenticateWithHandler(jwtHandler *jwt.JWTHandler, isOpenToken bool, ctx *fiber.Ctx) error {
|
||||||
// Log authentication attempt
|
// Log authentication attempt
|
||||||
ip := ctx.IP()
|
ip := ctx.IP()
|
||||||
userAgent := ctx.Get("User-Agent")
|
userAgent := ctx.Get("User-Agent")
|
||||||
|
|
||||||
authHeader := ctx.Get("Authorization")
|
authHeader := ctx.Get("Authorization")
|
||||||
|
|
||||||
|
if jwtHandler.IsOpenToken && !isOpenToken {
|
||||||
|
logging.Error("Authentication failed: attempting to authenticate with open token")
|
||||||
|
return ctx.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||||
|
"error": "Wrong token type used",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if authHeader == "" {
|
if authHeader == "" {
|
||||||
logging.Error("Authentication failed: missing Authorization header from IP %s", ip)
|
logging.Error("Authentication failed: missing Authorization header from IP %s", ip)
|
||||||
return ctx.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
return ctx.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||||
@@ -77,7 +98,7 @@ func (m *AuthMiddleware) Authenticate(ctx *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, err := jwt.ValidateToken(token)
|
claims, err := jwtHandler.ValidateToken(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logging.Error("Authentication failed: invalid token from IP %s, User-Agent: %s, Error: %v", ip, userAgent, err)
|
logging.Error("Authentication failed: invalid token from IP %s, User-Agent: %s, Error: %v", ip, userAgent, err)
|
||||||
return ctx.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
return ctx.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||||
@@ -85,6 +106,13 @@ func (m *AuthMiddleware) Authenticate(ctx *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !jwtHandler.IsOpenToken && claims.IsOpenToken {
|
||||||
|
logging.Error("Authentication failed: attempting to authenticate with open token")
|
||||||
|
return ctx.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||||
|
"error": "Wrong token type used",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Additional security: validate user ID format
|
// Additional security: validate user ID format
|
||||||
if claims.UserID == "" || len(claims.UserID) < 10 {
|
if claims.UserID == "" || len(claims.UserID) < 10 {
|
||||||
logging.Error("Authentication failed: invalid user ID in token from IP %s", ip)
|
logging.Error("Authentication failed: invalid user ID in token from IP %s", ip)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package security
|
package security
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"acc-server-manager/local/utl/graceful"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -22,22 +23,28 @@ func NewRateLimiter() *RateLimiter {
|
|||||||
requests: make(map[string][]time.Time),
|
requests: make(map[string][]time.Time),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up old entries every 5 minutes
|
// Use graceful shutdown for cleanup goroutine
|
||||||
go rl.cleanup()
|
shutdownManager := graceful.GetManager()
|
||||||
|
shutdownManager.RunGoroutine(func(ctx context.Context) {
|
||||||
|
rl.cleanupWithContext(ctx)
|
||||||
|
})
|
||||||
|
|
||||||
return rl
|
return rl
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanup removes old entries from the rate limiter
|
// cleanup removes old entries from the rate limiter
|
||||||
func (rl *RateLimiter) cleanup() {
|
func (rl *RateLimiter) cleanupWithContext(ctx context.Context) {
|
||||||
ticker := time.NewTicker(5 * time.Minute)
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for range ticker.C {
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
rl.mutex.Lock()
|
rl.mutex.Lock()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for key, times := range rl.requests {
|
for key, times := range rl.requests {
|
||||||
// Remove entries older than 1 hour
|
|
||||||
filtered := make([]time.Time, 0, len(times))
|
filtered := make([]time.Time, 0, len(times))
|
||||||
for _, t := range times {
|
for _, t := range times {
|
||||||
if now.Sub(t) < time.Hour {
|
if now.Sub(t) < time.Hour {
|
||||||
@@ -52,6 +59,7 @@ func (rl *RateLimiter) cleanup() {
|
|||||||
}
|
}
|
||||||
rl.mutex.Unlock()
|
rl.mutex.Unlock()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SecurityMiddleware provides comprehensive security middleware
|
// SecurityMiddleware provides comprehensive security middleware
|
||||||
@@ -189,13 +197,13 @@ func (sm *SecurityMiddleware) InputSanitization() fiber.Handler {
|
|||||||
|
|
||||||
// sanitizeInput removes potentially dangerous patterns from input
|
// sanitizeInput removes potentially dangerous patterns from input
|
||||||
func sanitizeInput(input string) string {
|
func sanitizeInput(input string) string {
|
||||||
// Remove common XSS patterns
|
|
||||||
dangerous := []string{
|
dangerous := []string{
|
||||||
"<script",
|
"<script",
|
||||||
"</script>",
|
"</script>",
|
||||||
"javascript:",
|
"javascript:",
|
||||||
"vbscript:",
|
"vbscript:",
|
||||||
"data:text/html",
|
"data:text/html",
|
||||||
|
"data:application",
|
||||||
"onload=",
|
"onload=",
|
||||||
"onerror=",
|
"onerror=",
|
||||||
"onclick=",
|
"onclick=",
|
||||||
@@ -204,25 +212,46 @@ func sanitizeInput(input string) string {
|
|||||||
"onblur=",
|
"onblur=",
|
||||||
"onchange=",
|
"onchange=",
|
||||||
"onsubmit=",
|
"onsubmit=",
|
||||||
|
"onkeydown=",
|
||||||
|
"onkeyup=",
|
||||||
"<iframe",
|
"<iframe",
|
||||||
"<object",
|
"<object",
|
||||||
"<embed",
|
"<embed",
|
||||||
"<link",
|
"<link",
|
||||||
"<meta",
|
"<meta",
|
||||||
"<style",
|
"<style",
|
||||||
|
"<form",
|
||||||
|
"<input",
|
||||||
|
"<button",
|
||||||
|
"<svg",
|
||||||
|
"<math",
|
||||||
|
"expression(",
|
||||||
|
"@import",
|
||||||
|
"url(",
|
||||||
|
"\\x",
|
||||||
|
"\\u",
|
||||||
|
"&#x",
|
||||||
|
"&#",
|
||||||
}
|
}
|
||||||
|
|
||||||
result := strings.ToLower(input)
|
result := input
|
||||||
|
lowerInput := strings.ToLower(input)
|
||||||
|
|
||||||
for _, pattern := range dangerous {
|
for _, pattern := range dangerous {
|
||||||
result = strings.ReplaceAll(result, pattern, "")
|
if strings.Contains(lowerInput, pattern) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the sanitized version is very different, it might be malicious
|
if strings.Contains(result, "\x00") {
|
||||||
if len(result) < len(input)/2 {
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return input
|
if len(strings.TrimSpace(result)) == 0 && len(input) > 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateContentType ensures only expected content types are accepted
|
// ValidateContentType ensures only expected content types are accepted
|
||||||
@@ -349,3 +378,24 @@ func (sm *SecurityMiddleware) TimeoutMiddleware(timeout time.Duration) fiber.Han
|
|||||||
return c.Next()
|
return c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (sm *SecurityMiddleware) RequestContextTimeout(timeout time.Duration) fiber.Handler {
|
||||||
|
return func(c *fiber.Ctx) error {
|
||||||
|
ctx, cancel := context.WithTimeout(c.UserContext(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- c.Next()
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
return c.Status(fiber.StatusRequestTimeout).JSON(fiber.Map{
|
||||||
|
"error": "Request timeout",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
124
local/migrations/003_update_state_history_sessions.go
Normal file
124
local/migrations/003_update_state_history_sessions.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"acc-server-manager/local/utl/logging"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpdateStateHistorySessions migrates tables from integer IDs to UUIDs
|
||||||
|
type UpdateStateHistorySessions struct {
|
||||||
|
DB *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUpdateStateHistorySessions creates a new UUID migration
|
||||||
|
func NewUpdateStateHistorySessions(db *gorm.DB) *UpdateStateHistorySessions {
|
||||||
|
return &UpdateStateHistorySessions{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Up executes the migration
|
||||||
|
func (m *UpdateStateHistorySessions) Up() error {
|
||||||
|
logging.Info("Checking UUID migration...")
|
||||||
|
|
||||||
|
// Check if migration is needed by looking at the servers table structure
|
||||||
|
if !m.needsMigration() {
|
||||||
|
logging.Info("UUID migration not needed - tables already use UUID primary keys")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.Info("Starting UUID migration...")
|
||||||
|
|
||||||
|
// Check if migration has already been applied
|
||||||
|
var migrationRecord MigrationRecord
|
||||||
|
err := m.DB.Where("migration_name = ?", "002_migrate_to_uuid").First(&migrationRecord).Error
|
||||||
|
if err == nil {
|
||||||
|
logging.Info("UUID migration already applied, skipping")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create migration tracking table if it doesn't exist
|
||||||
|
if err := m.DB.AutoMigrate(&MigrationRecord{}); err != nil {
|
||||||
|
return fmt.Errorf("failed to create migration tracking table: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the UUID migration using the existing migration function
|
||||||
|
logging.Info("Executing UUID migration...")
|
||||||
|
if err := runUUIDMigrationSQL(m.DB); err != nil {
|
||||||
|
return fmt.Errorf("failed to execute UUID migration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.Info("UUID migration completed successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// needsMigration checks if the UUID migration is needed by examining table structure
|
||||||
|
func (m *UpdateStateHistorySessions) needsMigration() bool {
|
||||||
|
// Check if servers table exists and has integer primary key
|
||||||
|
var result struct {
|
||||||
|
Exists bool `gorm:"column:exists"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := m.DB.Raw(`
|
||||||
|
SELECT count(*) > 0 as exists FROM state_history
|
||||||
|
WHERE length(session) > 1 LIMIT 1;
|
||||||
|
`).Scan(&result).Error
|
||||||
|
|
||||||
|
if err != nil || !result.Exists {
|
||||||
|
// Table doesn't exist or no primary key found - assume no migration needed
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return result.Exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// Down reverses the migration (not implemented for safety)
|
||||||
|
func (m *UpdateStateHistorySessions) Down() error {
|
||||||
|
logging.Error("UUID migration rollback is not supported for data safety reasons")
|
||||||
|
return fmt.Errorf("UUID migration rollback is not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
// runUpdateStateHistorySessionsMigration executes the UUID migration using the SQL file
|
||||||
|
func runUpdateStateHistorySessionsMigration(db *gorm.DB) error {
|
||||||
|
// Disable foreign key constraints during migration
|
||||||
|
if err := db.Exec("PRAGMA foreign_keys=OFF").Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to disable foreign keys: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start transaction
|
||||||
|
tx := db.Begin()
|
||||||
|
if tx.Error != nil {
|
||||||
|
return fmt.Errorf("failed to start transaction: %v", tx.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
migrationSQL := "UPDATE state_history SET session = upper(substr(session, 1, 1));"
|
||||||
|
|
||||||
|
// Execute the migration
|
||||||
|
if err := tx.Exec(string(migrationSQL)).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("failed to execute migration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit transaction
|
||||||
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to commit migration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-enable foreign key constraints
|
||||||
|
if err := db.Exec("PRAGMA foreign_keys=ON").Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to re-enable foreign keys: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunUpdateStateHistorySessionsMigration is a convenience function to run the migration
|
||||||
|
func RunUpdateStateHistorySessionsMigration(db *gorm.DB) error {
|
||||||
|
migration := NewUpdateStateHistorySessions(db)
|
||||||
|
return migration.Up()
|
||||||
|
}
|
||||||
@@ -82,7 +82,7 @@ type Session struct {
|
|||||||
HourOfDay IntString `json:"hourOfDay"`
|
HourOfDay IntString `json:"hourOfDay"`
|
||||||
DayOfWeekend IntString `json:"dayOfWeekend"`
|
DayOfWeekend IntString `json:"dayOfWeekend"`
|
||||||
TimeMultiplier IntString `json:"timeMultiplier"`
|
TimeMultiplier IntString `json:"timeMultiplier"`
|
||||||
SessionType string `json:"sessionType"`
|
SessionType TrackSession `json:"sessionType"`
|
||||||
SessionDurationMinutes IntString `json:"sessionDurationMinutes"`
|
SessionDurationMinutes IntString `json:"sessionDurationMinutes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ type State struct {
|
|||||||
|
|
||||||
type ServerState struct {
|
type ServerState struct {
|
||||||
sync.RWMutex `swaggerignore:"-" json:"-"`
|
sync.RWMutex `swaggerignore:"-" json:"-"`
|
||||||
Session string `json:"session"`
|
Session TrackSession `json:"session"`
|
||||||
SessionStart time.Time `json:"sessionStart"`
|
SessionStart time.Time `json:"sessionStart"`
|
||||||
PlayerCount int `json:"playerCount"`
|
PlayerCount int `json:"playerCount"`
|
||||||
Track string `json:"track"`
|
Track string `json:"track"`
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -13,7 +16,7 @@ type StateHistoryFilter struct {
|
|||||||
DateRangeFilter // Adds date range filtering
|
DateRangeFilter // Adds date range filtering
|
||||||
|
|
||||||
// Additional fields specific to state history
|
// Additional fields specific to state history
|
||||||
Session string `query:"session"`
|
Session TrackSession `query:"session"`
|
||||||
MinPlayers *int `query:"min_players"`
|
MinPlayers *int `query:"min_players"`
|
||||||
MaxPlayers *int `query:"max_players"`
|
MaxPlayers *int `query:"max_players"`
|
||||||
}
|
}
|
||||||
@@ -52,10 +55,60 @@ func (f *StateHistoryFilter) ApplyFilter(query *gorm.DB) *gorm.DB {
|
|||||||
return query
|
return query
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TrackSession string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SessionPractice TrackSession = "P"
|
||||||
|
SessionQualify TrackSession = "Q"
|
||||||
|
SessionRace TrackSession = "R"
|
||||||
|
SessionUnknown TrackSession = "U"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (i *TrackSession) UnmarshalJSON(b []byte) error {
|
||||||
|
var str string
|
||||||
|
if err := json.Unmarshal(b, &str); err == nil {
|
||||||
|
*i = ToTrackSession(str)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("invalid TrackSession value")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i TrackSession) Humanize() string {
|
||||||
|
switch i {
|
||||||
|
case SessionPractice:
|
||||||
|
return "Practice"
|
||||||
|
case SessionQualify:
|
||||||
|
return "Qualifying"
|
||||||
|
case SessionRace:
|
||||||
|
return "Race"
|
||||||
|
default:
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ToTrackSession(i string) TrackSession {
|
||||||
|
sessionAbrv := strings.ToUpper(i[:1])
|
||||||
|
switch sessionAbrv {
|
||||||
|
case "P":
|
||||||
|
return SessionPractice
|
||||||
|
case "Q":
|
||||||
|
return SessionQualify
|
||||||
|
case "R":
|
||||||
|
return SessionRace
|
||||||
|
default:
|
||||||
|
return SessionUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i TrackSession) ToString() string {
|
||||||
|
return string(i)
|
||||||
|
}
|
||||||
|
|
||||||
type StateHistory struct {
|
type StateHistory struct {
|
||||||
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
||||||
ServerID uuid.UUID `json:"serverId" gorm:"not null;type:uuid"`
|
ServerID uuid.UUID `json:"serverId" gorm:"not null;type:uuid"`
|
||||||
Session string `json:"session"`
|
Session TrackSession `json:"session"`
|
||||||
Track string `json:"track"`
|
Track string `json:"track"`
|
||||||
PlayerCount int `json:"playerCount"`
|
PlayerCount int `json:"playerCount"`
|
||||||
DateCreated time.Time `json:"dateCreated"`
|
DateCreated time.Time `json:"dateCreated"`
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
|
import "github.com/google/uuid"
|
||||||
|
|
||||||
type SessionCount struct {
|
type SessionCount struct {
|
||||||
Name string `json:"name"`
|
Name TrackSession `json:"name"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,9 +29,9 @@ type StateHistoryStats struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RecentSession struct {
|
type RecentSession struct {
|
||||||
ID uint `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
Date string `json:"date"`
|
Date string `json:"date"`
|
||||||
Type string `json:"type"`
|
Type TrackSession `json:"type"`
|
||||||
Track string `json:"track"`
|
Track string `json:"track"`
|
||||||
Duration int `json:"duration"`
|
Duration int `json:"duration"`
|
||||||
Players int `json:"players"`
|
Players int `json:"players"`
|
||||||
|
|||||||
168
local/model/steam_2fa.go
Normal file
168
local/model/steam_2fa.go
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Steam2FAStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Steam2FAStatusIdle Steam2FAStatus = "idle"
|
||||||
|
Steam2FAStatusPending Steam2FAStatus = "pending"
|
||||||
|
Steam2FAStatusComplete Steam2FAStatus = "complete"
|
||||||
|
Steam2FAStatusError Steam2FAStatus = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Steam2FARequest struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status Steam2FAStatus `json:"status"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
RequestTime time.Time `json:"requestTime"`
|
||||||
|
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||||
|
ErrorMsg string `json:"errorMsg,omitempty"`
|
||||||
|
ServerID *uuid.UUID `json:"serverId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steam2FAManager manages 2FA requests and responses
|
||||||
|
type Steam2FAManager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
requests map[string]*Steam2FARequest
|
||||||
|
channels map[string]chan bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSteam2FAManager() *Steam2FAManager {
|
||||||
|
return &Steam2FAManager{
|
||||||
|
requests: make(map[string]*Steam2FARequest),
|
||||||
|
channels: make(map[string]chan bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Steam2FAManager) CreateRequest(message string, serverID *uuid.UUID) *Steam2FARequest {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
id := uuid.New().String()
|
||||||
|
request := &Steam2FARequest{
|
||||||
|
ID: id,
|
||||||
|
Status: Steam2FAStatusPending,
|
||||||
|
Message: message,
|
||||||
|
RequestTime: time.Now(),
|
||||||
|
ServerID: serverID,
|
||||||
|
}
|
||||||
|
|
||||||
|
m.requests[id] = request
|
||||||
|
m.channels[id] = make(chan bool, 1)
|
||||||
|
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Steam2FAManager) GetRequest(id string) (*Steam2FARequest, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
req, exists := m.requests[id]
|
||||||
|
return req, exists
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Steam2FAManager) GetPendingRequests() []*Steam2FARequest {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
var pending []*Steam2FARequest
|
||||||
|
for _, req := range m.requests {
|
||||||
|
if req.Status == Steam2FAStatusPending {
|
||||||
|
pending = append(pending, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Steam2FAManager) CompleteRequest(id string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
req, exists := m.requests[id]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("request %s not found", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Status != Steam2FAStatusPending {
|
||||||
|
return fmt.Errorf("request %s is not pending", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
req.Status = Steam2FAStatusComplete
|
||||||
|
req.CompletedAt = &now
|
||||||
|
|
||||||
|
// Signal the waiting goroutine
|
||||||
|
if ch, exists := m.channels[id]; exists {
|
||||||
|
select {
|
||||||
|
case ch <- true:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Steam2FAManager) ErrorRequest(id string, errorMsg string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
req, exists := m.requests[id]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("request %s not found", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Status = Steam2FAStatusError
|
||||||
|
req.ErrorMsg = errorMsg
|
||||||
|
|
||||||
|
// Signal the waiting goroutine with error
|
||||||
|
if ch, exists := m.channels[id]; exists {
|
||||||
|
select {
|
||||||
|
case ch <- false:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Steam2FAManager) WaitForCompletion(id string, timeout time.Duration) (bool, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
ch, exists := m.channels[id]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return false, fmt.Errorf("request %s not found", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case success := <-ch:
|
||||||
|
return success, nil
|
||||||
|
case <-time.After(timeout):
|
||||||
|
// Timeout - mark as error
|
||||||
|
m.ErrorRequest(id, "timeout waiting for 2FA confirmation")
|
||||||
|
return false, fmt.Errorf("timeout waiting for 2FA confirmation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Steam2FAManager) CleanupOldRequests(maxAge time.Duration) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
cutoff := time.Now().Add(-maxAge)
|
||||||
|
for id, req := range m.requests {
|
||||||
|
if req.RequestTime.Before(cutoff) {
|
||||||
|
delete(m.requests, id)
|
||||||
|
if ch, exists := m.channels[id]; exists {
|
||||||
|
close(ch)
|
||||||
|
delete(m.channels, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"acc-server-manager/local/model"
|
||||||
|
"acc-server-manager/local/utl/graceful"
|
||||||
|
"acc-server-manager/local/utl/logging"
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"go.uber.org/dig"
|
"go.uber.org/dig"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,4 +23,29 @@ func InitializeRepositories(c *dig.Container) {
|
|||||||
c.Provide(NewLookupRepository)
|
c.Provide(NewLookupRepository)
|
||||||
c.Provide(NewSteamCredentialsRepository)
|
c.Provide(NewSteamCredentialsRepository)
|
||||||
c.Provide(NewMembershipRepository)
|
c.Provide(NewMembershipRepository)
|
||||||
|
|
||||||
|
// Provide the Steam2FAManager as a singleton
|
||||||
|
if err := c.Provide(func() *model.Steam2FAManager {
|
||||||
|
manager := model.NewSteam2FAManager()
|
||||||
|
|
||||||
|
// Use graceful shutdown manager for cleanup goroutine
|
||||||
|
shutdownManager := graceful.GetManager()
|
||||||
|
shutdownManager.RunGoroutine(func(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(15 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
manager.CleanupOldRequests(30 * time.Minute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return manager
|
||||||
|
}); err != nil {
|
||||||
|
logging.Panic("unable to initialize steam 2fa manager")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ func (r *StateHistoryRepository) GetRecentSessions(ctx context.Context, filter *
|
|||||||
FROM state_histories
|
FROM state_histories
|
||||||
WHERE server_id = ? AND date_created BETWEEN ? AND ?
|
WHERE server_id = ? AND date_created BETWEEN ? AND ?
|
||||||
GROUP BY session_id
|
GROUP BY session_id
|
||||||
HAVING COUNT(*) > 1 AND MAX(player_count) > 0
|
HAVING MAX(player_count) > 0
|
||||||
ORDER BY date DESC
|
ORDER BY date DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -22,13 +22,17 @@ type CacheInvalidator interface {
|
|||||||
type MembershipService struct {
|
type MembershipService struct {
|
||||||
repo *repository.MembershipRepository
|
repo *repository.MembershipRepository
|
||||||
cacheInvalidator CacheInvalidator
|
cacheInvalidator CacheInvalidator
|
||||||
|
jwtHandler *jwt.JWTHandler
|
||||||
|
openJwtHandler *jwt.OpenJWTHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMembershipService creates a new MembershipService.
|
// NewMembershipService creates a new MembershipService.
|
||||||
func NewMembershipService(repo *repository.MembershipRepository) *MembershipService {
|
func NewMembershipService(repo *repository.MembershipRepository, jwtHandler *jwt.JWTHandler, openJwtHandler *jwt.OpenJWTHandler) *MembershipService {
|
||||||
return &MembershipService{
|
return &MembershipService{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
cacheInvalidator: nil, // Will be set later via SetCacheInvalidator
|
cacheInvalidator: nil, // Will be set later via SetCacheInvalidator
|
||||||
|
jwtHandler: jwtHandler,
|
||||||
|
openJwtHandler: openJwtHandler,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,18 +42,32 @@ func (s *MembershipService) SetCacheInvalidator(invalidator CacheInvalidator) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Login authenticates a user and returns a JWT.
|
// Login authenticates a user and returns a JWT.
|
||||||
func (s *MembershipService) Login(ctx context.Context, username, password string) (string, error) {
|
func (s *MembershipService) HandleLogin(ctx context.Context, username, password string) (*model.User, error) {
|
||||||
user, err := s.repo.FindUserByUsername(ctx, username)
|
user, err := s.repo.FindUserByUsername(ctx, username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", errors.New("invalid credentials")
|
return nil, errors.New("invalid credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use secure password verification with constant-time comparison
|
// Use secure password verification with constant-time comparison
|
||||||
if err := user.VerifyPassword(password); err != nil {
|
if err := user.VerifyPassword(password); err != nil {
|
||||||
return "", errors.New("invalid credentials")
|
return nil, errors.New("invalid credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
return jwt.GenerateToken(user)
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login authenticates a user and returns a JWT.
|
||||||
|
func (s *MembershipService) Login(ctx context.Context, username, password string) (string, error) {
|
||||||
|
user, err := s.HandleLogin(ctx, username, password)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.jwtHandler.GenerateToken(user.ID.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MembershipService) GenerateOpenToken(ctx context.Context, userId string) (string, error) {
|
||||||
|
return s.openJwtHandler.GenerateToken(userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateUser creates a new user.
|
// CreateUser creates a new user.
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ func (s *ServerService) insertStateHistory(serverID uuid.UUID, state *model.Serv
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ServerService) updateSessionDuration(server *model.Server, sessionType string) {
|
func (s *ServerService) updateSessionDuration(server *model.Server, sessionType model.TrackSession) {
|
||||||
// Get configs using helper methods
|
// Get configs using helper methods
|
||||||
event, err := s.configService.GetEventConfig(server)
|
event, err := s.configService.GetEventConfig(server)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -337,7 +337,7 @@ func (s *ServerService) CreateServer(ctx *fiber.Ctx, server *model.Server) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Install server using SteamCMD
|
// Install server using SteamCMD
|
||||||
if err := s.steamService.InstallServer(ctx.UserContext(), server.GetServerPath()); err != nil {
|
if err := s.steamService.InstallServer(ctx.UserContext(), server.GetServerPath(), &server.ID); err != nil {
|
||||||
return fmt.Errorf("failed to install server: %v", err)
|
return fmt.Errorf("failed to install server: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,7 +450,7 @@ func (s *ServerService) UpdateServer(ctx *fiber.Ctx, server *model.Server) error
|
|||||||
|
|
||||||
// Update server files if path changed
|
// Update server files if path changed
|
||||||
if existingServer.Path != server.Path {
|
if existingServer.Path != server.Path {
|
||||||
if err := s.steamService.InstallServer(ctx.UserContext(), server.Path); err != nil {
|
if err := s.steamService.InstallServer(ctx.UserContext(), server.Path, &server.ID); err != nil {
|
||||||
return fmt.Errorf("failed to install server to new location: %v", err)
|
return fmt.Errorf("failed to install server to new location: %v", err)
|
||||||
}
|
}
|
||||||
// Clean up old installation
|
// Clean up old installation
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ func InitializeServices(c *dig.Container) {
|
|||||||
|
|
||||||
logging.Debug("Registering services")
|
logging.Debug("Registering services")
|
||||||
// Provide services
|
// Provide services
|
||||||
|
c.Provide(NewSteamService)
|
||||||
c.Provide(NewServerService)
|
c.Provide(NewServerService)
|
||||||
c.Provide(NewStateHistoryService)
|
c.Provide(NewStateHistoryService)
|
||||||
c.Provide(NewServiceControlService)
|
c.Provide(NewServiceControlService)
|
||||||
c.Provide(NewConfigService)
|
c.Provide(NewConfigService)
|
||||||
c.Provide(NewLookupService)
|
c.Provide(NewLookupService)
|
||||||
c.Provide(NewSteamService)
|
|
||||||
c.Provide(NewWindowsService)
|
c.Provide(NewWindowsService)
|
||||||
c.Provide(NewFirewallService)
|
c.Provide(NewFirewallService)
|
||||||
c.Provide(NewMembershipService)
|
c.Provide(NewMembershipService)
|
||||||
|
|||||||
@@ -6,10 +6,14 @@ import (
|
|||||||
"acc-server-manager/local/utl/command"
|
"acc-server-manager/local/utl/command"
|
||||||
"acc-server-manager/local/utl/env"
|
"acc-server-manager/local/utl/env"
|
||||||
"acc-server-manager/local/utl/logging"
|
"acc-server-manager/local/utl/logging"
|
||||||
|
"acc-server-manager/local/utl/security"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -18,16 +22,26 @@ const (
|
|||||||
|
|
||||||
type SteamService struct {
|
type SteamService struct {
|
||||||
executor *command.CommandExecutor
|
executor *command.CommandExecutor
|
||||||
|
interactiveExecutor *command.InteractiveCommandExecutor
|
||||||
repository *repository.SteamCredentialsRepository
|
repository *repository.SteamCredentialsRepository
|
||||||
|
tfaManager *model.Steam2FAManager
|
||||||
|
pathValidator *security.PathValidator
|
||||||
|
downloadVerifier *security.DownloadVerifier
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSteamService(repository *repository.SteamCredentialsRepository) *SteamService {
|
func NewSteamService(repository *repository.SteamCredentialsRepository, tfaManager *model.Steam2FAManager) *SteamService {
|
||||||
return &SteamService{
|
baseExecutor := &command.CommandExecutor{
|
||||||
executor: &command.CommandExecutor{
|
|
||||||
ExePath: "powershell",
|
ExePath: "powershell",
|
||||||
LogOutput: true,
|
LogOutput: true,
|
||||||
},
|
}
|
||||||
|
|
||||||
|
return &SteamService{
|
||||||
|
executor: baseExecutor,
|
||||||
|
interactiveExecutor: command.NewInteractiveCommandExecutor(baseExecutor, tfaManager),
|
||||||
repository: repository,
|
repository: repository,
|
||||||
|
tfaManager: tfaManager,
|
||||||
|
pathValidator: security.NewPathValidator(),
|
||||||
|
downloadVerifier: security.NewDownloadVerifier(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +56,7 @@ func (s *SteamService) SaveCredentials(ctx context.Context, creds *model.SteamCr
|
|||||||
return s.repository.Save(ctx, creds)
|
return s.repository.Save(ctx, creds)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SteamService) ensureSteamCMD(ctx context.Context) error {
|
func (s *SteamService) ensureSteamCMD(_ context.Context) error {
|
||||||
// Get SteamCMD path from environment variable
|
// Get SteamCMD path from environment variable
|
||||||
steamCMDPath := env.GetSteamCMDPath()
|
steamCMDPath := env.GetSteamCMDPath()
|
||||||
steamCMDDir := filepath.Dir(steamCMDPath)
|
steamCMDDir := filepath.Dir(steamCMDPath)
|
||||||
@@ -57,10 +71,13 @@ func (s *SteamService) ensureSteamCMD(ctx context.Context) error {
|
|||||||
return fmt.Errorf("failed to create SteamCMD directory: %v", err)
|
return fmt.Errorf("failed to create SteamCMD directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download and install SteamCMD
|
// Download and install SteamCMD securely
|
||||||
logging.Info("Downloading SteamCMD...")
|
logging.Info("Downloading SteamCMD...")
|
||||||
if err := s.executor.Execute("-Command",
|
steamCMDZip := filepath.Join(steamCMDDir, "steamcmd.zip")
|
||||||
"Invoke-WebRequest -Uri 'https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip' -OutFile 'steamcmd.zip'"); err != nil {
|
if err := s.downloadVerifier.VerifyAndDownload(
|
||||||
|
"https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip",
|
||||||
|
steamCMDZip,
|
||||||
|
""); err != nil {
|
||||||
return fmt.Errorf("failed to download SteamCMD: %v", err)
|
return fmt.Errorf("failed to download SteamCMD: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,11 +93,16 @@ func (s *SteamService) ensureSteamCMD(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SteamService) InstallServer(ctx context.Context, installPath string) error {
|
func (s *SteamService) InstallServer(ctx context.Context, installPath string, serverID *uuid.UUID) error {
|
||||||
if err := s.ensureSteamCMD(ctx); err != nil {
|
if err := s.ensureSteamCMD(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate installation path for security
|
||||||
|
if err := s.pathValidator.ValidateInstallPath(installPath); err != nil {
|
||||||
|
return fmt.Errorf("invalid installation path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Convert to absolute path and ensure proper Windows path format
|
// Convert to absolute path and ensure proper Windows path format
|
||||||
absPath, err := filepath.Abs(installPath)
|
absPath, err := filepath.Abs(installPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -102,41 +124,50 @@ func (s *SteamService) InstallServer(ctx context.Context, installPath string) er
|
|||||||
// Get SteamCMD path from environment variable
|
// Get SteamCMD path from environment variable
|
||||||
steamCMDPath := env.GetSteamCMDPath()
|
steamCMDPath := env.GetSteamCMDPath()
|
||||||
|
|
||||||
// Build SteamCMD command
|
// Build SteamCMD command arguments
|
||||||
args := []string{
|
steamCMDArgs := []string{
|
||||||
"-nologo",
|
|
||||||
"-noprofile",
|
|
||||||
steamCMDPath,
|
|
||||||
"+force_install_dir", absPath,
|
"+force_install_dir", absPath,
|
||||||
"+login",
|
"+login",
|
||||||
}
|
}
|
||||||
|
|
||||||
if creds != nil && creds.Username != "" {
|
if creds != nil && creds.Username != "" {
|
||||||
args = append(args, creds.Username)
|
steamCMDArgs = append(steamCMDArgs, creds.Username)
|
||||||
if creds.Password != "" {
|
if creds.Password != "" {
|
||||||
args = append(args, creds.Password)
|
steamCMDArgs = append(steamCMDArgs, creds.Password)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
args = append(args, "anonymous")
|
steamCMDArgs = append(steamCMDArgs, "anonymous")
|
||||||
}
|
}
|
||||||
|
|
||||||
args = append(args,
|
steamCMDArgs = append(steamCMDArgs,
|
||||||
"+app_update", ACCServerAppID,
|
"+app_update", ACCServerAppID,
|
||||||
"validate",
|
"validate",
|
||||||
"+quit",
|
"+quit",
|
||||||
)
|
)
|
||||||
|
|
||||||
// Run SteamCMD
|
// Build PowerShell arguments to execute SteamCMD directly
|
||||||
|
// This matches the format: powershell -nologo -noprofile c:\steamcmd\steamcmd.exe +args...
|
||||||
|
args := []string{"-nologo", "-noprofile"}
|
||||||
|
args = append(args, steamCMDPath)
|
||||||
|
args = append(args, steamCMDArgs...)
|
||||||
|
|
||||||
|
// Use interactive executor to handle potential 2FA prompts with timeout
|
||||||
logging.Info("Installing ACC server to %s...", absPath)
|
logging.Info("Installing ACC server to %s...", absPath)
|
||||||
if err := s.executor.Execute(args...); err != nil {
|
|
||||||
|
// Create a context with timeout to prevent hanging indefinitely
|
||||||
|
timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := s.interactiveExecutor.ExecuteInteractive(timeoutCtx, serverID, args...); err != nil {
|
||||||
|
if timeoutCtx.Err() == context.DeadlineExceeded {
|
||||||
|
return fmt.Errorf("SteamCMD operation timed out after 10 minutes")
|
||||||
|
}
|
||||||
return fmt.Errorf("failed to run SteamCMD: %v", err)
|
return fmt.Errorf("failed to run SteamCMD: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add a delay to allow Steam to properly cleanup
|
// Add a delay to allow Steam to properly cleanup
|
||||||
logging.Info("Waiting for Steam operations to complete...")
|
logging.Info("Waiting for Steam operations to complete...")
|
||||||
if err := s.executor.Execute("-Command", "Start-Sleep -Seconds 5"); err != nil {
|
time.Sleep(5 * time.Second)
|
||||||
logging.Warn("Failed to wait after Steam operations: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify installation
|
// Verify installation
|
||||||
exePath := filepath.Join(absPath, "server", "accServer.exe")
|
exePath := filepath.Join(absPath, "server", "accServer.exe")
|
||||||
@@ -148,8 +179,8 @@ func (s *SteamService) InstallServer(ctx context.Context, installPath string) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SteamService) UpdateServer(ctx context.Context, installPath string) error {
|
func (s *SteamService) UpdateServer(ctx context.Context, installPath string, serverID *uuid.UUID) error {
|
||||||
return s.InstallServer(ctx, installPath) // Same process as install
|
return s.InstallServer(ctx, installPath, serverID) // Same process as install
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SteamService) UninstallServer(installPath string) error {
|
func (s *SteamService) UninstallServer(installPath string) error {
|
||||||
|
|||||||
64
local/utl/audit/audit.go
Normal file
64
local/utl/audit/audit.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package audit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"acc-server-manager/local/utl/logging"
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuditAction string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ActionLogin AuditAction = "LOGIN"
|
||||||
|
ActionLogout AuditAction = "LOGOUT"
|
||||||
|
ActionServerCreate AuditAction = "SERVER_CREATE"
|
||||||
|
ActionServerUpdate AuditAction = "SERVER_UPDATE"
|
||||||
|
ActionServerDelete AuditAction = "SERVER_DELETE"
|
||||||
|
ActionServerStart AuditAction = "SERVER_START"
|
||||||
|
ActionServerStop AuditAction = "SERVER_STOP"
|
||||||
|
ActionUserCreate AuditAction = "USER_CREATE"
|
||||||
|
ActionUserUpdate AuditAction = "USER_UPDATE"
|
||||||
|
ActionUserDelete AuditAction = "USER_DELETE"
|
||||||
|
ActionConfigUpdate AuditAction = "CONFIG_UPDATE"
|
||||||
|
ActionSteamAuth AuditAction = "STEAM_AUTH"
|
||||||
|
ActionPermissionGrant AuditAction = "PERMISSION_GRANT"
|
||||||
|
ActionPermissionRevoke AuditAction = "PERMISSION_REVOKE"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuditEntry struct {
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Action AuditAction `json:"action"`
|
||||||
|
Resource string `json:"resource"`
|
||||||
|
Details string `json:"details"`
|
||||||
|
IPAddress string `json:"ip_address"`
|
||||||
|
UserAgent string `json:"user_agent"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogAction(ctx context.Context, userID, username string, action AuditAction, resource, details, ipAddress, userAgent string, success bool) {
|
||||||
|
logging.InfoWithContext("AUDIT", "User %s (%s) performed %s on %s from %s - Success: %t - Details: %s",
|
||||||
|
username, userID, action, resource, ipAddress, success, details)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogAuthAction(ctx context.Context, username, ipAddress, userAgent string, success bool, details string) {
|
||||||
|
action := ActionLogin
|
||||||
|
if !success {
|
||||||
|
details = "Failed: " + details
|
||||||
|
}
|
||||||
|
|
||||||
|
LogAction(ctx, "", username, action, "authentication", details, ipAddress, userAgent, success)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogServerAction(ctx context.Context, userID, username string, action AuditAction, serverID, ipAddress, userAgent string, success bool, details string) {
|
||||||
|
LogAction(ctx, userID, username, action, "server:"+serverID, details, ipAddress, userAgent, success)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogUserManagementAction(ctx context.Context, adminUserID, adminUsername string, action AuditAction, targetUserID, ipAddress, userAgent string, success bool, details string) {
|
||||||
|
LogAction(ctx, adminUserID, adminUsername, action, "user:"+targetUserID, details, ipAddress, userAgent, success)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LogConfigAction(ctx context.Context, userID, username string, configType, ipAddress, userAgent string, success bool, details string) {
|
||||||
|
LogAction(ctx, userID, username, ActionConfigUpdate, "config:"+configType, details, ipAddress, userAgent, success)
|
||||||
|
}
|
||||||
238
local/utl/command/interactive_executor.go
Normal file
238
local/utl/command/interactive_executor.go
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
package command
|
||||||
|
|
||||||
|
import (
|
||||||
|
"acc-server-manager/local/model"
|
||||||
|
"acc-server-manager/local/utl/logging"
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InteractiveCommandExecutor extends CommandExecutor to handle interactive commands
|
||||||
|
type InteractiveCommandExecutor struct {
|
||||||
|
*CommandExecutor
|
||||||
|
tfaManager *model.Steam2FAManager
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewInteractiveCommandExecutor(baseExecutor *CommandExecutor, tfaManager *model.Steam2FAManager) *InteractiveCommandExecutor {
|
||||||
|
return &InteractiveCommandExecutor{
|
||||||
|
CommandExecutor: baseExecutor,
|
||||||
|
tfaManager: tfaManager,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteInteractive runs a command that may require 2FA input
|
||||||
|
func (e *InteractiveCommandExecutor) ExecuteInteractive(ctx context.Context, serverID *uuid.UUID, args ...string) error {
|
||||||
|
cmd := exec.CommandContext(ctx, e.ExePath, args...)
|
||||||
|
|
||||||
|
if e.WorkDir != "" {
|
||||||
|
cmd.Dir = e.WorkDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create pipes for stdin, stdout, and stderr
|
||||||
|
stdin, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create stdin pipe: %v", err)
|
||||||
|
}
|
||||||
|
defer stdin.Close()
|
||||||
|
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create stdout pipe: %v", err)
|
||||||
|
}
|
||||||
|
defer stdout.Close()
|
||||||
|
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create stderr pipe: %v", err)
|
||||||
|
}
|
||||||
|
defer stderr.Close()
|
||||||
|
|
||||||
|
logging.Info("Executing interactive command: %s %s", e.ExePath, strings.Join(args, " "))
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return fmt.Errorf("failed to start command: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create channels for output monitoring
|
||||||
|
outputDone := make(chan error, 1)
|
||||||
|
cmdDone := make(chan error, 1)
|
||||||
|
|
||||||
|
// Monitor stdout and stderr for 2FA prompts
|
||||||
|
go e.monitorOutput(ctx, stdout, stderr, serverID, outputDone)
|
||||||
|
|
||||||
|
// Wait for the command to finish in a separate goroutine
|
||||||
|
go func() {
|
||||||
|
cmdDone <- cmd.Wait()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for both command and output monitoring to complete
|
||||||
|
var cmdErr, outputErr error
|
||||||
|
completedCount := 0
|
||||||
|
|
||||||
|
for completedCount < 2 {
|
||||||
|
select {
|
||||||
|
case cmdErr = <-cmdDone:
|
||||||
|
completedCount++
|
||||||
|
logging.Info("Command execution completed")
|
||||||
|
case outputErr = <-outputDone:
|
||||||
|
completedCount++
|
||||||
|
logging.Info("Output monitoring completed")
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if outputErr != nil {
|
||||||
|
logging.Warn("Output monitoring error: %v", outputErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmdErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *InteractiveCommandExecutor) monitorOutput(ctx context.Context, stdout, stderr io.Reader, serverID *uuid.UUID, done chan error) {
|
||||||
|
defer func() {
|
||||||
|
select {
|
||||||
|
case done <- nil:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Create scanners for both outputs
|
||||||
|
stdoutScanner := bufio.NewScanner(stdout)
|
||||||
|
stderrScanner := bufio.NewScanner(stderr)
|
||||||
|
|
||||||
|
outputChan := make(chan string, 100) // Buffered channel to prevent blocking
|
||||||
|
readersDone := make(chan struct{}, 2)
|
||||||
|
|
||||||
|
// Read from stdout
|
||||||
|
go func() {
|
||||||
|
defer func() { readersDone <- struct{}{} }()
|
||||||
|
for stdoutScanner.Scan() {
|
||||||
|
line := stdoutScanner.Text()
|
||||||
|
if e.LogOutput {
|
||||||
|
logging.Info("STDOUT: %s", line)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case outputChan <- line:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := stdoutScanner.Err(); err != nil {
|
||||||
|
logging.Warn("Stdout scanner error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Read from stderr
|
||||||
|
go func() {
|
||||||
|
defer func() { readersDone <- struct{}{} }()
|
||||||
|
for stderrScanner.Scan() {
|
||||||
|
line := stderrScanner.Text()
|
||||||
|
if e.LogOutput {
|
||||||
|
logging.Info("STDERR: %s", line)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case outputChan <- line:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := stderrScanner.Err(); err != nil {
|
||||||
|
logging.Warn("Stderr scanner error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Monitor for completion and 2FA prompts
|
||||||
|
readersFinished := 0
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
done <- ctx.Err()
|
||||||
|
return
|
||||||
|
case <-readersDone:
|
||||||
|
readersFinished++
|
||||||
|
if readersFinished == 2 {
|
||||||
|
// Both readers are done, close output channel and finish monitoring
|
||||||
|
close(outputChan)
|
||||||
|
// Drain any remaining output
|
||||||
|
for line := range outputChan {
|
||||||
|
if e.is2FAPrompt(line) {
|
||||||
|
if err := e.handle2FAPrompt(ctx, line, serverID); err != nil {
|
||||||
|
logging.Error("Failed to handle 2FA prompt: %v", err)
|
||||||
|
done <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case line, ok := <-outputChan:
|
||||||
|
if !ok {
|
||||||
|
// Channel closed, we're done
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this line indicates a 2FA prompt
|
||||||
|
if e.is2FAPrompt(line) {
|
||||||
|
if err := e.handle2FAPrompt(ctx, line, serverID); err != nil {
|
||||||
|
logging.Error("Failed to handle 2FA prompt: %v", err)
|
||||||
|
done <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *InteractiveCommandExecutor) is2FAPrompt(line string) bool {
|
||||||
|
// Common SteamCMD 2FA prompts
|
||||||
|
twoFAKeywords := []string{
|
||||||
|
"please enter your steam guard code",
|
||||||
|
"steam guard",
|
||||||
|
"two-factor",
|
||||||
|
"authentication code",
|
||||||
|
"please check your steam mobile app",
|
||||||
|
"confirm in application",
|
||||||
|
}
|
||||||
|
|
||||||
|
lowerLine := strings.ToLower(line)
|
||||||
|
for _, keyword := range twoFAKeywords {
|
||||||
|
if strings.Contains(lowerLine, keyword) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *InteractiveCommandExecutor) handle2FAPrompt(_ context.Context, promptLine string, serverID *uuid.UUID) error {
|
||||||
|
logging.Info("2FA prompt detected: %s", promptLine)
|
||||||
|
|
||||||
|
// Create a 2FA request
|
||||||
|
request := e.tfaManager.CreateRequest(promptLine, serverID)
|
||||||
|
logging.Info("Created 2FA request with ID: %s", request.ID)
|
||||||
|
|
||||||
|
// Wait for user to complete the 2FA process
|
||||||
|
// Use a reasonable timeout (e.g., 5 minutes)
|
||||||
|
timeout := 5 * time.Minute
|
||||||
|
success, err := e.tfaManager.WaitForCompletion(request.ID, timeout)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logging.Error("2FA completion failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !success {
|
||||||
|
logging.Error("2FA was not completed successfully")
|
||||||
|
return fmt.Errorf("2FA authentication failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.Info("2FA completed successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ type RouteGroups struct {
|
|||||||
StateHistory fiber.Router
|
StateHistory fiber.Router
|
||||||
Membership fiber.Router
|
Membership fiber.Router
|
||||||
System fiber.Router
|
System fiber.Router
|
||||||
|
Steam2FA fiber.Router
|
||||||
}
|
}
|
||||||
|
|
||||||
func CheckError(err error) {
|
func CheckError(err error) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
Version = "0.0.1"
|
Version = "0.10.7"
|
||||||
Prefix = "v1"
|
Prefix = "v1"
|
||||||
Secret string
|
Secret string
|
||||||
SecretCode string
|
SecretCode string
|
||||||
|
|||||||
67
local/utl/errors/safe_error.go
Normal file
67
local/utl/errors/safe_error.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package errors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"acc-server-manager/local/utl/logging"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SafeError struct {
|
||||||
|
Message string
|
||||||
|
Code int
|
||||||
|
Fatal bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *SafeError) Error() string {
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSafeError(message string, code int) *SafeError {
|
||||||
|
return &SafeError{
|
||||||
|
Message: message,
|
||||||
|
Code: code,
|
||||||
|
Fatal: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFatalError(message string, code int) *SafeError {
|
||||||
|
return &SafeError{
|
||||||
|
Message: message,
|
||||||
|
Code: code,
|
||||||
|
Fatal: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleError(err error, context string) {
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if safeErr, ok := err.(*SafeError); ok {
|
||||||
|
if safeErr.Fatal {
|
||||||
|
logging.Error("Fatal error in %s: %s", context, safeErr.Message)
|
||||||
|
if os.Getenv("ENVIRONMENT") == "production" {
|
||||||
|
logging.Error("Application shutting down due to fatal error")
|
||||||
|
os.Exit(safeErr.Code)
|
||||||
|
} else {
|
||||||
|
logging.Warn("Fatal error occurred but not exiting in non-production environment")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logging.Error("Error in %s: %s", context, safeErr.Message)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logging.Error("Unexpected error in %s: %v", context, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SafeFatal(message string, args ...interface{}) {
|
||||||
|
formattedMessage := fmt.Sprintf(message, args...)
|
||||||
|
err := NewFatalError(formattedMessage, 1)
|
||||||
|
HandleError(err, "application")
|
||||||
|
}
|
||||||
|
|
||||||
|
func SafeLog(message string, args ...interface{}) {
|
||||||
|
formattedMessage := fmt.Sprintf(message, args...)
|
||||||
|
err := NewSafeError(formattedMessage, 0)
|
||||||
|
HandleError(err, "application")
|
||||||
|
}
|
||||||
91
local/utl/graceful/shutdown.go
Normal file
91
local/utl/graceful/shutdown.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package graceful
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ShutdownManager struct {
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
wg sync.WaitGroup
|
||||||
|
handlers []func() error
|
||||||
|
mutex sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
var globalManager *ShutdownManager
|
||||||
|
var once sync.Once
|
||||||
|
|
||||||
|
func GetManager() *ShutdownManager {
|
||||||
|
once.Do(func() {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
globalManager = &ShutdownManager{
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
handlers: make([]func() error, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
go globalManager.watchSignals()
|
||||||
|
})
|
||||||
|
return globalManager
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *ShutdownManager) watchSignals() {
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
<-sigChan
|
||||||
|
sm.Shutdown(30 * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *ShutdownManager) AddHandler(handler func() error) {
|
||||||
|
sm.mutex.Lock()
|
||||||
|
defer sm.mutex.Unlock()
|
||||||
|
sm.handlers = append(sm.handlers, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *ShutdownManager) Context() context.Context {
|
||||||
|
return sm.ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *ShutdownManager) AddGoroutine() {
|
||||||
|
sm.wg.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *ShutdownManager) GoroutineDone() {
|
||||||
|
sm.wg.Done()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *ShutdownManager) RunGoroutine(fn func(ctx context.Context)) {
|
||||||
|
sm.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer sm.wg.Done()
|
||||||
|
fn(sm.ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *ShutdownManager) Shutdown(timeout time.Duration) {
|
||||||
|
sm.cancel()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
sm.wg.Wait()
|
||||||
|
|
||||||
|
sm.mutex.Lock()
|
||||||
|
for _, handler := range sm.handlers {
|
||||||
|
handler()
|
||||||
|
}
|
||||||
|
sm.mutex.Unlock()
|
||||||
|
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(timeout):
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,88 +2,109 @@ package jwt
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"acc-server-manager/local/model"
|
"acc-server-manager/local/model"
|
||||||
|
"acc-server-manager/local/utl/errors"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
goerrors "errors"
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v4"
|
"github.com/golang-jwt/jwt/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SecretKey holds the JWT signing key loaded from environment
|
|
||||||
var SecretKey []byte
|
|
||||||
|
|
||||||
// Claims represents the JWT claims.
|
// Claims represents the JWT claims.
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
|
IsOpenToken bool `json:"is_open_token"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
// init initializes the JWT secret key from environment variable
|
type JWTHandler struct {
|
||||||
func Init() {
|
SecretKey []byte
|
||||||
jwtSecret := os.Getenv("JWT_SECRET")
|
IsOpenToken bool
|
||||||
if jwtSecret == "" {
|
}
|
||||||
log.Fatal("JWT_SECRET environment variable is required and cannot be empty")
|
|
||||||
|
type OpenJWTHandler struct {
|
||||||
|
*JWTHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewJWTHandler creates a new JWTHandler instance with the provided secret key.
|
||||||
|
func NewOpenJWTHandler(jwtSecret string) *OpenJWTHandler {
|
||||||
|
jwtHandler := NewJWTHandler(jwtSecret)
|
||||||
|
jwtHandler.IsOpenToken = true
|
||||||
|
return &OpenJWTHandler{
|
||||||
|
JWTHandler: jwtHandler,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewJWTHandler creates a new JWTHandler instance with the provided secret key.
|
||||||
|
func NewJWTHandler(jwtSecret string) *JWTHandler {
|
||||||
|
if jwtSecret == "" {
|
||||||
|
errors.SafeFatal("JWT_SECRET environment variable is required and cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
var secretKey []byte
|
||||||
|
|
||||||
// Decode base64 secret if it looks like base64, otherwise use as-is
|
// Decode base64 secret if it looks like base64, otherwise use as-is
|
||||||
if decoded, err := base64.StdEncoding.DecodeString(jwtSecret); err == nil && len(decoded) >= 32 {
|
if decoded, err := base64.StdEncoding.DecodeString(jwtSecret); err == nil && len(decoded) >= 32 {
|
||||||
SecretKey = decoded
|
secretKey = decoded
|
||||||
} else {
|
} else {
|
||||||
SecretKey = []byte(jwtSecret)
|
secretKey = []byte(jwtSecret)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure minimum key length for security
|
// Ensure minimum key length for security
|
||||||
if len(SecretKey) < 32 {
|
if len(secretKey) < 32 {
|
||||||
log.Fatal("JWT_SECRET must be at least 32 bytes long for security")
|
errors.SafeFatal("JWT_SECRET must be at least 32 bytes long for security")
|
||||||
|
}
|
||||||
|
return &JWTHandler{
|
||||||
|
SecretKey: secretKey,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateSecretKey generates a cryptographically secure random key for JWT signing
|
// GenerateSecretKey generates a cryptographically secure random key for JWT signing
|
||||||
// This is a utility function for generating new secrets, not used in normal operation
|
// This is a utility function for generating new secrets, not used in normal operation
|
||||||
func GenerateSecretKey() string {
|
func (jh *JWTHandler) GenerateSecretKey() string {
|
||||||
key := make([]byte, 64) // 512 bits
|
key := make([]byte, 64) // 512 bits
|
||||||
if _, err := rand.Read(key); err != nil {
|
if _, err := rand.Read(key); err != nil {
|
||||||
log.Fatal("Failed to generate random key: ", err)
|
errors.SafeFatal("Failed to generate random key: %v", err)
|
||||||
}
|
}
|
||||||
return base64.StdEncoding.EncodeToString(key)
|
return base64.StdEncoding.EncodeToString(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateToken generates a new JWT for a given user.
|
// GenerateToken generates a new JWT for a given user.
|
||||||
func GenerateToken(user *model.User) (string, error) {
|
func (jh *JWTHandler) GenerateToken(userId string) (string, error) {
|
||||||
expirationTime := time.Now().Add(24 * time.Hour)
|
expirationTime := time.Now().Add(24 * time.Hour)
|
||||||
claims := &Claims{
|
claims := &Claims{
|
||||||
UserID: user.ID.String(),
|
UserID: userId,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
||||||
},
|
},
|
||||||
|
IsOpenToken: jh.IsOpenToken,
|
||||||
}
|
}
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
return token.SignedString(SecretKey)
|
return token.SignedString(jh.SecretKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateTokenWithExpiry(user *model.User, expiry time.Time) (string, error) {
|
func (jh *JWTHandler) GenerateTokenWithExpiry(user *model.User, expiry time.Time) (string, error) {
|
||||||
expirationTime := expiry
|
expirationTime := expiry
|
||||||
claims := &Claims{
|
claims := &Claims{
|
||||||
UserID: user.ID.String(),
|
UserID: user.ID.String(),
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
||||||
},
|
},
|
||||||
|
IsOpenToken: jh.IsOpenToken,
|
||||||
}
|
}
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
return token.SignedString(SecretKey)
|
return token.SignedString(jh.SecretKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateToken validates a JWT and returns the claims if the token is valid.
|
// ValidateToken validates a JWT and returns the claims if the token is valid.
|
||||||
func ValidateToken(tokenString string) (*Claims, error) {
|
func (jh *JWTHandler) ValidateToken(tokenString string) (*Claims, error) {
|
||||||
claims := &Claims{}
|
claims := &Claims{}
|
||||||
|
|
||||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
||||||
return SecretKey, nil
|
return jh.SecretKey, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -91,7 +112,7 @@ func ValidateToken(tokenString string) (*Claims, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !token.Valid {
|
if !token.Valid {
|
||||||
return nil, errors.New("invalid token")
|
return nil, goerrors.New("invalid token")
|
||||||
}
|
}
|
||||||
|
|
||||||
return claims, nil
|
return claims, nil
|
||||||
|
|||||||
76
local/utl/security/download_verifier.go
Normal file
76
local/utl/security/download_verifier.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DownloadVerifier struct {
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDownloadVerifier() *DownloadVerifier {
|
||||||
|
return &DownloadVerifier{
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DisableKeepAlives: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dv *DownloadVerifier) VerifyAndDownload(url, outputPath, expectedSHA256 string) error {
|
||||||
|
if url == "" {
|
||||||
|
return fmt.Errorf("URL cannot be empty")
|
||||||
|
}
|
||||||
|
if outputPath == "" {
|
||||||
|
return fmt.Errorf("output path cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", "ACC-Server-Manager/1.0")
|
||||||
|
|
||||||
|
resp, err := dv.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to download: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("download failed with status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Create(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create output file: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
hash := sha256.New()
|
||||||
|
writer := io.MultiWriter(file, hash)
|
||||||
|
|
||||||
|
_, err = io.Copy(writer, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("failed to write file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if expectedSHA256 != "" {
|
||||||
|
actualHash := fmt.Sprintf("%x", hash.Sum(nil))
|
||||||
|
if actualHash != expectedSHA256 {
|
||||||
|
os.Remove(outputPath)
|
||||||
|
return fmt.Errorf("file hash mismatch: expected %s, got %s", expectedSHA256, actualHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
94
local/utl/security/path_validator.go
Normal file
94
local/utl/security/path_validator.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package security
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PathValidator struct {
|
||||||
|
allowedBasePaths []string
|
||||||
|
blockedPatterns []*regexp.Regexp
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPathValidator() *PathValidator {
|
||||||
|
blockedPatterns := []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`\.\.`),
|
||||||
|
regexp.MustCompile(`^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$`),
|
||||||
|
regexp.MustCompile(`\x00`),
|
||||||
|
regexp.MustCompile(`^\\\\`),
|
||||||
|
regexp.MustCompile(`^[a-zA-Z]:\\Windows`),
|
||||||
|
regexp.MustCompile(`^[a-zA-Z]:\\Program Files`),
|
||||||
|
}
|
||||||
|
|
||||||
|
return &PathValidator{
|
||||||
|
allowedBasePaths: []string{
|
||||||
|
`C:\ACC-Servers`,
|
||||||
|
`D:\ACC-Servers`,
|
||||||
|
`E:\ACC-Servers`,
|
||||||
|
`C:\SteamCMD`,
|
||||||
|
`D:\SteamCMD`,
|
||||||
|
`E:\SteamCMD`,
|
||||||
|
},
|
||||||
|
blockedPatterns: blockedPatterns,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pv *PathValidator) ValidateInstallPath(path string) error {
|
||||||
|
if path == "" {
|
||||||
|
return fmt.Errorf("path cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanPath := filepath.Clean(path)
|
||||||
|
absPath, err := filepath.Abs(cleanPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pattern := range pv.blockedPatterns {
|
||||||
|
if pattern.MatchString(absPath) || pattern.MatchString(strings.ToUpper(filepath.Base(absPath))) {
|
||||||
|
return fmt.Errorf("path contains forbidden patterns")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed := false
|
||||||
|
for _, basePath := range pv.allowedBasePaths {
|
||||||
|
if strings.HasPrefix(strings.ToLower(absPath), strings.ToLower(basePath)) {
|
||||||
|
allowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !allowed {
|
||||||
|
return fmt.Errorf("path must be within allowed directories: %v", pv.allowedBasePaths)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(absPath) > 260 {
|
||||||
|
return fmt.Errorf("path too long (max 260 characters)")
|
||||||
|
}
|
||||||
|
|
||||||
|
parentDir := filepath.Dir(absPath)
|
||||||
|
if parentInfo, err := os.Stat(parentDir); err == nil {
|
||||||
|
if !parentInfo.IsDir() {
|
||||||
|
return fmt.Errorf("parent path is not a directory")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pv *PathValidator) AddAllowedBasePath(path string) error {
|
||||||
|
absPath, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid base path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pv.allowedBasePaths = append(pv.allowedBasePaths, absPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pv *PathValidator) GetAllowedBasePaths() []string {
|
||||||
|
return append([]string(nil), pv.allowedBasePaths...)
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ func Start(di *dig.Container) *fiber.App {
|
|||||||
app.Use(securityMW.SecurityHeaders())
|
app.Use(securityMW.SecurityHeaders())
|
||||||
app.Use(securityMW.LogSecurityEvents())
|
app.Use(securityMW.LogSecurityEvents())
|
||||||
app.Use(securityMW.TimeoutMiddleware(30 * time.Second))
|
app.Use(securityMW.TimeoutMiddleware(30 * time.Second))
|
||||||
|
app.Use(securityMW.RequestContextTimeout(60 * time.Second))
|
||||||
app.Use(securityMW.RequestSizeLimit(10 * 1024 * 1024)) // 10MB
|
app.Use(securityMW.RequestSizeLimit(10 * 1024 * 1024)) // 10MB
|
||||||
app.Use(securityMW.ValidateUserAgent())
|
app.Use(securityMW.ValidateUserAgent())
|
||||||
app.Use(securityMW.ValidateContentType("application/json", "application/x-www-form-urlencoded", "multipart/form-data"))
|
app.Use(securityMW.ValidateContentType("application/json", "application/x-www-form-urlencoded", "multipart/form-data"))
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type StateChange int
|
type StateChange int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
PlayerCount StateChange = iota
|
PlayerCount StateChange = iota
|
||||||
Session
|
Session
|
||||||
)
|
)
|
||||||
|
|
||||||
var StateChanges = map[StateChange]string {
|
var StateChanges = map[StateChange]string{
|
||||||
PlayerCount: "player-count",
|
PlayerCount: "player-count",
|
||||||
Session: "session",
|
Session: "session",
|
||||||
}
|
}
|
||||||
@@ -47,13 +48,13 @@ func NewRegexHandler(str string, test string) *StateRegexHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rh *StateRegexHandler) Test(line string) bool{
|
func (rh *StateRegexHandler) Test(line string) bool {
|
||||||
return strings.Contains(line, rh.test)
|
return strings.Contains(line, rh.test)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rh *StateRegexHandler) Count(line string) int{
|
func (rh *StateRegexHandler) Count(line string) int {
|
||||||
var count int = 0
|
var count int = 0
|
||||||
rh.Contains(line, func (strs ...string) {
|
rh.Contains(line, func(strs ...string) {
|
||||||
if len(strs) == 2 {
|
if len(strs) == 2 {
|
||||||
if ct, err := strconv.Atoi(strs[1]); err == nil {
|
if ct, err := strconv.Atoi(strs[1]); err == nil {
|
||||||
count = ct
|
count = ct
|
||||||
@@ -63,10 +64,10 @@ func (rh *StateRegexHandler) Count(line string) int{
|
|||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rh *StateRegexHandler) Change(line string) (string, string){
|
func (rh *StateRegexHandler) Change(line string) (string, string) {
|
||||||
var old string = ""
|
var old string = ""
|
||||||
var new string = ""
|
var new string = ""
|
||||||
rh.Contains(line, func (strs ...string) {
|
rh.Contains(line, func(strs ...string) {
|
||||||
if len(strs) == 3 {
|
if len(strs) == 3 {
|
||||||
old = strs[1]
|
old = strs[1]
|
||||||
new = strs[2]
|
new = strs[2]
|
||||||
@@ -93,6 +94,7 @@ func TailLogFile(path string, callback func(string)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type LogStateType int
|
type LogStateType int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
SessionChange LogStateType = iota
|
SessionChange LogStateType = iota
|
||||||
LeaderboardUpdate
|
LeaderboardUpdate
|
||||||
@@ -101,7 +103,7 @@ const (
|
|||||||
RemovingDeadConnection
|
RemovingDeadConnection
|
||||||
)
|
)
|
||||||
|
|
||||||
var logStateContain = map[LogStateType]string {
|
var logStateContain = map[LogStateType]string{
|
||||||
SessionChange: "Session changed",
|
SessionChange: "Session changed",
|
||||||
LeaderboardUpdate: "Updated leaderboard for",
|
LeaderboardUpdate: "Updated leaderboard for",
|
||||||
UDPCount: "Udp message count",
|
UDPCount: "Udp message count",
|
||||||
@@ -115,7 +117,7 @@ var udpCountRegex = NewRegexHandler(`Udp message count (\d+) client`, logStateCo
|
|||||||
var clientsOnlineRegex = NewRegexHandler(`(\d+) client\(s\) online`, logStateContain[ClientsOnline])
|
var clientsOnlineRegex = NewRegexHandler(`(\d+) client\(s\) online`, logStateContain[ClientsOnline])
|
||||||
var removingDeadConnectionsRegex = NewRegexHandler(`Removing dead connection`, logStateContain[RemovingDeadConnection])
|
var removingDeadConnectionsRegex = NewRegexHandler(`Removing dead connection`, logStateContain[RemovingDeadConnection])
|
||||||
|
|
||||||
var logStateRegex = map[LogStateType]*StateRegexHandler {
|
var logStateRegex = map[LogStateType]*StateRegexHandler{
|
||||||
SessionChange: sessionChangeRegex,
|
SessionChange: sessionChangeRegex,
|
||||||
LeaderboardUpdate: leaderboardUpdateRegex,
|
LeaderboardUpdate: leaderboardUpdateRegex,
|
||||||
UDPCount: udpCountRegex,
|
UDPCount: udpCountRegex,
|
||||||
@@ -125,7 +127,7 @@ var logStateRegex = map[LogStateType]*StateRegexHandler {
|
|||||||
|
|
||||||
func (instance *AccServerInstance) HandleLogLine(line string) {
|
func (instance *AccServerInstance) HandleLogLine(line string) {
|
||||||
for logState, regexHandler := range logStateRegex {
|
for logState, regexHandler := range logStateRegex {
|
||||||
if (regexHandler.Test(line)) {
|
if regexHandler.Test(line) {
|
||||||
switch logState {
|
switch logState {
|
||||||
case LeaderboardUpdate:
|
case LeaderboardUpdate:
|
||||||
case UDPCount:
|
case UDPCount:
|
||||||
@@ -134,7 +136,9 @@ func (instance *AccServerInstance) HandleLogLine(line string) {
|
|||||||
instance.UpdatePlayerCount(count)
|
instance.UpdatePlayerCount(count)
|
||||||
case SessionChange:
|
case SessionChange:
|
||||||
_, new := regexHandler.Change(line)
|
_, new := regexHandler.Change(line)
|
||||||
instance.UpdateSessionChange(new)
|
|
||||||
|
trackSession := model.ToTrackSession(new)
|
||||||
|
instance.UpdateSessionChange(trackSession)
|
||||||
case RemovingDeadConnection:
|
case RemovingDeadConnection:
|
||||||
instance.UpdatePlayerCount(instance.State.PlayerCount - 1)
|
instance.UpdatePlayerCount(instance.State.PlayerCount - 1)
|
||||||
}
|
}
|
||||||
@@ -148,23 +152,23 @@ func (instance *AccServerInstance) UpdateState(callback func(state *model.Server
|
|||||||
state.Lock()
|
state.Lock()
|
||||||
defer state.Unlock()
|
defer state.Unlock()
|
||||||
callback(state, &changes)
|
callback(state, &changes)
|
||||||
if (len(changes) > 0) {
|
if len(changes) > 0 {
|
||||||
instance.OnStateChange(state, changes...)
|
instance.OnStateChange(state, changes...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (instance *AccServerInstance) UpdatePlayerCount(count int) {
|
func (instance *AccServerInstance) UpdatePlayerCount(count int) {
|
||||||
if (count < 0) {
|
if count < 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
instance.UpdateState(func (state *model.ServerState, changes *[]StateChange) {
|
instance.UpdateState(func(state *model.ServerState, changes *[]StateChange) {
|
||||||
if (count == state.PlayerCount) {
|
if count == state.PlayerCount {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (count > 0 && state.PlayerCount == 0) {
|
if count > 0 && state.PlayerCount == 0 {
|
||||||
state.SessionStart = time.Now()
|
state.SessionStart = time.Now()
|
||||||
*changes = append(*changes, Session)
|
*changes = append(*changes, Session)
|
||||||
} else if (count == 0) {
|
} else if count == 0 {
|
||||||
state.SessionStart = time.Time{}
|
state.SessionStart = time.Time{}
|
||||||
*changes = append(*changes, Session)
|
*changes = append(*changes, Session)
|
||||||
}
|
}
|
||||||
@@ -173,12 +177,12 @@ func (instance *AccServerInstance) UpdatePlayerCount(count int) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (instance *AccServerInstance) UpdateSessionChange(session string) {
|
func (instance *AccServerInstance) UpdateSessionChange(session model.TrackSession) {
|
||||||
instance.UpdateState(func (state *model.ServerState, changes *[]StateChange) {
|
instance.UpdateState(func(state *model.ServerState, changes *[]StateChange) {
|
||||||
if (session == state.Session) {
|
if session == state.Session {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (state.PlayerCount > 0) {
|
if state.PlayerCount > 0 {
|
||||||
state.SessionStart = time.Now()
|
state.SessionStart = time.Now()
|
||||||
} else {
|
} else {
|
||||||
state.SessionStart = time.Time{}
|
state.SessionStart = time.Time{}
|
||||||
|
|||||||
1178
swagger/docs.go
1178
swagger/docs.go
File diff suppressed because it is too large
Load Diff
1177
swagger/swagger.json
1177
swagger/swagger.json
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
basePath: /api/v1
|
basePath: /v1
|
||||||
definitions:
|
definitions:
|
||||||
error_handler.ErrorResponse:
|
error_handler.ErrorResponse:
|
||||||
properties:
|
properties:
|
||||||
@@ -92,6 +92,35 @@ definitions:
|
|||||||
- StatusRestarting
|
- StatusRestarting
|
||||||
- StatusStarting
|
- StatusStarting
|
||||||
- StatusRunning
|
- StatusRunning
|
||||||
|
model.Steam2FARequest:
|
||||||
|
properties:
|
||||||
|
completedAt:
|
||||||
|
type: string
|
||||||
|
errorMsg:
|
||||||
|
type: string
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
requestTime:
|
||||||
|
type: string
|
||||||
|
serverId:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
$ref: '#/definitions/model.Steam2FAStatus'
|
||||||
|
type: object
|
||||||
|
model.Steam2FAStatus:
|
||||||
|
enum:
|
||||||
|
- idle
|
||||||
|
- pending
|
||||||
|
- complete
|
||||||
|
- error
|
||||||
|
type: string
|
||||||
|
x-enum-varnames:
|
||||||
|
- Steam2FAStatusIdle
|
||||||
|
- Steam2FAStatusPending
|
||||||
|
- Steam2FAStatusComplete
|
||||||
|
- Steam2FAStatusError
|
||||||
model.User:
|
model.User:
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
@@ -103,11 +132,20 @@ definitions:
|
|||||||
username:
|
username:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
host: localhost:3000
|
service.UpdateUserRequest:
|
||||||
|
properties:
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
roleId:
|
||||||
|
type: string
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
host: acc-api.jurmanovic.com
|
||||||
info:
|
info:
|
||||||
contact:
|
contact:
|
||||||
name: ACC Server Manager Support
|
name: ACC Server Manager Support
|
||||||
url: https://github.com/yourusername/acc-server-manager
|
url: https://github.com/FJurmanovic/acc-server-manager
|
||||||
description: API for managing Assetto Corsa Competizione dedicated servers
|
description: API for managing Assetto Corsa Competizione dedicated servers
|
||||||
license:
|
license:
|
||||||
name: MIT
|
name: MIT
|
||||||
@@ -115,6 +153,62 @@ info:
|
|||||||
title: ACC Server Manager API
|
title: ACC Server Manager API
|
||||||
version: "1.0"
|
version: "1.0"
|
||||||
paths:
|
paths:
|
||||||
|
/api/server:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Get a list of all ACC servers with filtering options
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: name
|
||||||
|
type: string
|
||||||
|
- in: query
|
||||||
|
name: page
|
||||||
|
type: integer
|
||||||
|
- in: query
|
||||||
|
name: pageSize
|
||||||
|
type: integer
|
||||||
|
- in: query
|
||||||
|
name: serverID
|
||||||
|
type: string
|
||||||
|
- in: query
|
||||||
|
name: serviceName
|
||||||
|
type: string
|
||||||
|
- in: query
|
||||||
|
name: sortBy
|
||||||
|
type: string
|
||||||
|
- in: query
|
||||||
|
name: sortDesc
|
||||||
|
type: boolean
|
||||||
|
- in: query
|
||||||
|
name: status
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: List of servers
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/model.ServerAPI'
|
||||||
|
type: array
|
||||||
|
"400":
|
||||||
|
description: Invalid filter parameters
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal server error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: List all servers (API format)
|
||||||
|
tags:
|
||||||
|
- Server
|
||||||
/auth/login:
|
/auth/login:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
@@ -157,6 +251,228 @@ paths:
|
|||||||
summary: User login
|
summary: User login
|
||||||
tags:
|
tags:
|
||||||
- Authentication
|
- Authentication
|
||||||
|
/auth/me:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Get details of the currently authenticated user
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Current user details
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.User'
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
"404":
|
||||||
|
description: User not found
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Get current user details
|
||||||
|
tags:
|
||||||
|
- Authentication
|
||||||
|
/auth/open-token:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Generate an open token for a user
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: JWT token
|
||||||
|
schema:
|
||||||
|
properties:
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
"400":
|
||||||
|
description: Invalid request body
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
"401":
|
||||||
|
description: Invalid credentials
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal server error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
summary: Generate an open token
|
||||||
|
tags:
|
||||||
|
- Authentication
|
||||||
|
/lookup/car-models:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Get a list of all available ACC car models with their identifiers
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: List of car models
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
properties:
|
||||||
|
class:
|
||||||
|
type: string
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal server error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Get available car models
|
||||||
|
tags:
|
||||||
|
- Lookups
|
||||||
|
/lookup/cup-categories:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Get a list of all available racing cup categories
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: List of cup categories
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: number
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal server error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Get cup categories
|
||||||
|
tags:
|
||||||
|
- Lookups
|
||||||
|
/lookup/driver-categories:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Get a list of all driver categories (Bronze, Silver, Gold, Platinum)
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: List of driver categories
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
properties:
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
id:
|
||||||
|
type: number
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal server error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Get driver categories
|
||||||
|
tags:
|
||||||
|
- Lookups
|
||||||
|
/lookup/session-types:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Get a list of all available session types (Practice, Qualifying,
|
||||||
|
Race)
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: List of session types
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal server error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Get session types
|
||||||
|
tags:
|
||||||
|
- Lookups
|
||||||
|
/lookup/tracks:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Get a list of all available ACC tracks with their identifiers
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: List of tracks
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal server error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Get available tracks
|
||||||
|
tags:
|
||||||
|
- Lookups
|
||||||
/membership:
|
/membership:
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
@@ -238,117 +554,141 @@ paths:
|
|||||||
summary: Create a new user
|
summary: Create a new user
|
||||||
tags:
|
tags:
|
||||||
- User Management
|
- User Management
|
||||||
/server:
|
/membership/{id}:
|
||||||
get:
|
delete:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: Get a list of all ACC servers with filtering options
|
description: Delete a specific user by ID
|
||||||
parameters:
|
parameters:
|
||||||
- in: query
|
- description: User ID (UUID format)
|
||||||
name: name
|
in: path
|
||||||
type: string
|
name: id
|
||||||
- in: query
|
required: true
|
||||||
name: page
|
|
||||||
type: integer
|
|
||||||
- in: query
|
|
||||||
name: pageSize
|
|
||||||
type: integer
|
|
||||||
- in: query
|
|
||||||
name: serverID
|
|
||||||
type: string
|
|
||||||
- in: query
|
|
||||||
name: serviceName
|
|
||||||
type: string
|
|
||||||
- in: query
|
|
||||||
name: sortBy
|
|
||||||
type: string
|
|
||||||
- in: query
|
|
||||||
name: sortDesc
|
|
||||||
type: boolean
|
|
||||||
- in: query
|
|
||||||
name: status
|
|
||||||
type: string
|
type: string
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"204":
|
||||||
description: List of servers
|
description: User successfully deleted
|
||||||
schema:
|
|
||||||
items:
|
|
||||||
$ref: '#/definitions/model.ServerAPI'
|
|
||||||
type: array
|
|
||||||
"400":
|
"400":
|
||||||
description: Invalid filter parameters
|
description: Invalid user ID format
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/error_handler.ErrorResponse'
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
"401":
|
"401":
|
||||||
description: Unauthorized
|
description: Unauthorized
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/error_handler.ErrorResponse'
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
"500":
|
"403":
|
||||||
description: Internal server error
|
description: Insufficient permissions
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
"404":
|
||||||
|
description: User not found
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/error_handler.ErrorResponse'
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
security:
|
security:
|
||||||
- BearerAuth: []
|
- BearerAuth: []
|
||||||
summary: List all servers (API format)
|
summary: Delete user
|
||||||
tags:
|
tags:
|
||||||
- Server
|
- User Management
|
||||||
/v1/lookup/car-models:
|
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
description: Get a list of all available ACC car models with their identifiers
|
description: Get detailed information about a specific user
|
||||||
|
parameters:
|
||||||
|
- description: User ID (UUID format)
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: List of car models
|
description: User details
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.User'
|
||||||
|
"400":
|
||||||
|
description: Invalid user ID format
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
"404":
|
||||||
|
description: User not found
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Get user by ID
|
||||||
|
tags:
|
||||||
|
- User Management
|
||||||
|
put:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Update user details by ID
|
||||||
|
parameters:
|
||||||
|
- description: User ID (UUID format)
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
- description: Updated user details
|
||||||
|
in: body
|
||||||
|
name: user
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/service.UpdateUserRequest'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Updated user details
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.User'
|
||||||
|
"400":
|
||||||
|
description: Invalid request body or ID format
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
"403":
|
||||||
|
description: Insufficient permissions
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
"404":
|
||||||
|
description: User not found
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Update user
|
||||||
|
tags:
|
||||||
|
- User Management
|
||||||
|
/membership/roles:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Get a list of all available user roles
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: List of roles
|
||||||
schema:
|
schema:
|
||||||
items:
|
items:
|
||||||
properties:
|
$ref: '#/definitions/model.Role'
|
||||||
class:
|
|
||||||
type: string
|
|
||||||
id:
|
|
||||||
type: string
|
|
||||||
name:
|
|
||||||
type: string
|
|
||||||
type: object
|
|
||||||
type: array
|
type: array
|
||||||
"401":
|
"401":
|
||||||
description: Unauthorized
|
description: Unauthorized
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/error_handler.ErrorResponse'
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
"500":
|
"403":
|
||||||
description: Internal server error
|
description: Insufficient permissions
|
||||||
schema:
|
|
||||||
$ref: '#/definitions/error_handler.ErrorResponse'
|
|
||||||
security:
|
|
||||||
- BearerAuth: []
|
|
||||||
summary: Get available car models
|
|
||||||
tags:
|
|
||||||
- Lookups
|
|
||||||
/v1/lookup/cup-categories:
|
|
||||||
get:
|
|
||||||
consumes:
|
|
||||||
- application/json
|
|
||||||
description: Get a list of all available racing cup categories
|
|
||||||
produces:
|
|
||||||
- application/json
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: List of cup categories
|
|
||||||
schema:
|
|
||||||
items:
|
|
||||||
properties:
|
|
||||||
id:
|
|
||||||
type: number
|
|
||||||
name:
|
|
||||||
type: string
|
|
||||||
type: object
|
|
||||||
type: array
|
|
||||||
"401":
|
|
||||||
description: Unauthorized
|
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/error_handler.ErrorResponse'
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
"500":
|
"500":
|
||||||
@@ -357,111 +697,10 @@ paths:
|
|||||||
$ref: '#/definitions/error_handler.ErrorResponse'
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
security:
|
security:
|
||||||
- BearerAuth: []
|
- BearerAuth: []
|
||||||
summary: Get cup categories
|
summary: Get all roles
|
||||||
tags:
|
tags:
|
||||||
- Lookups
|
- User Management
|
||||||
/v1/lookup/driver-categories:
|
/server:
|
||||||
get:
|
|
||||||
consumes:
|
|
||||||
- application/json
|
|
||||||
description: Get a list of all driver categories (Bronze, Silver, Gold, Platinum)
|
|
||||||
produces:
|
|
||||||
- application/json
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: List of driver categories
|
|
||||||
schema:
|
|
||||||
items:
|
|
||||||
properties:
|
|
||||||
description:
|
|
||||||
type: string
|
|
||||||
id:
|
|
||||||
type: number
|
|
||||||
name:
|
|
||||||
type: string
|
|
||||||
type: object
|
|
||||||
type: array
|
|
||||||
"401":
|
|
||||||
description: Unauthorized
|
|
||||||
schema:
|
|
||||||
$ref: '#/definitions/error_handler.ErrorResponse'
|
|
||||||
"500":
|
|
||||||
description: Internal server error
|
|
||||||
schema:
|
|
||||||
$ref: '#/definitions/error_handler.ErrorResponse'
|
|
||||||
security:
|
|
||||||
- BearerAuth: []
|
|
||||||
summary: Get driver categories
|
|
||||||
tags:
|
|
||||||
- Lookups
|
|
||||||
/v1/lookup/session-types:
|
|
||||||
get:
|
|
||||||
consumes:
|
|
||||||
- application/json
|
|
||||||
description: Get a list of all available session types (Practice, Qualifying,
|
|
||||||
Race)
|
|
||||||
produces:
|
|
||||||
- application/json
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: List of session types
|
|
||||||
schema:
|
|
||||||
items:
|
|
||||||
properties:
|
|
||||||
code:
|
|
||||||
type: string
|
|
||||||
id:
|
|
||||||
type: string
|
|
||||||
name:
|
|
||||||
type: string
|
|
||||||
type: object
|
|
||||||
type: array
|
|
||||||
"401":
|
|
||||||
description: Unauthorized
|
|
||||||
schema:
|
|
||||||
$ref: '#/definitions/error_handler.ErrorResponse'
|
|
||||||
"500":
|
|
||||||
description: Internal server error
|
|
||||||
schema:
|
|
||||||
$ref: '#/definitions/error_handler.ErrorResponse'
|
|
||||||
security:
|
|
||||||
- BearerAuth: []
|
|
||||||
summary: Get session types
|
|
||||||
tags:
|
|
||||||
- Lookups
|
|
||||||
/v1/lookup/tracks:
|
|
||||||
get:
|
|
||||||
consumes:
|
|
||||||
- application/json
|
|
||||||
description: Get a list of all available ACC tracks with their identifiers
|
|
||||||
produces:
|
|
||||||
- application/json
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: List of tracks
|
|
||||||
schema:
|
|
||||||
items:
|
|
||||||
properties:
|
|
||||||
id:
|
|
||||||
type: string
|
|
||||||
name:
|
|
||||||
type: string
|
|
||||||
type: object
|
|
||||||
type: array
|
|
||||||
"401":
|
|
||||||
description: Unauthorized
|
|
||||||
schema:
|
|
||||||
$ref: '#/definitions/error_handler.ErrorResponse'
|
|
||||||
"500":
|
|
||||||
description: Internal server error
|
|
||||||
schema:
|
|
||||||
$ref: '#/definitions/error_handler.ErrorResponse'
|
|
||||||
security:
|
|
||||||
- BearerAuth: []
|
|
||||||
summary: Get available tracks
|
|
||||||
tags:
|
|
||||||
- Lookups
|
|
||||||
/v1/server:
|
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -556,7 +795,49 @@ paths:
|
|||||||
summary: Create a new ACC server
|
summary: Create a new ACC server
|
||||||
tags:
|
tags:
|
||||||
- Server
|
- Server
|
||||||
/v1/server/{id}:
|
/server/{id}:
|
||||||
|
delete:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Delete an existing ACC server
|
||||||
|
parameters:
|
||||||
|
- description: Server ID (UUID format)
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Deleted server details
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
"400":
|
||||||
|
description: Invalid server data or ID
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
"403":
|
||||||
|
description: Insufficient permissions
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
"404":
|
||||||
|
description: Server not found
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal server error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
summary: Delete an ACC server
|
||||||
|
tags:
|
||||||
|
- Server
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -643,7 +924,7 @@ paths:
|
|||||||
summary: Update an ACC server
|
summary: Update an ACC server
|
||||||
tags:
|
tags:
|
||||||
- Server
|
- Server
|
||||||
/v1/server/{id}/config:
|
/server/{id}/config:
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -688,7 +969,7 @@ paths:
|
|||||||
summary: List available configuration files
|
summary: List available configuration files
|
||||||
tags:
|
tags:
|
||||||
- Server Configuration
|
- Server Configuration
|
||||||
/v1/server/{id}/config/{file}:
|
/server/{id}/config/{file}:
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -792,20 +1073,7 @@ paths:
|
|||||||
summary: Update server configuration file
|
summary: Update server configuration file
|
||||||
tags:
|
tags:
|
||||||
- Server Configuration
|
- Server Configuration
|
||||||
/v1/service-control:
|
/server/{id}/service/{service}:
|
||||||
get:
|
|
||||||
description: Return service control status
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: OK
|
|
||||||
schema:
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
type: array
|
|
||||||
summary: Return service control status
|
|
||||||
tags:
|
|
||||||
- service-control
|
|
||||||
/v1/service-control/{service}:
|
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -849,7 +1117,7 @@ paths:
|
|||||||
summary: Get service status
|
summary: Get service status
|
||||||
tags:
|
tags:
|
||||||
- Service Control
|
- Service Control
|
||||||
/v1/service-control/restart:
|
/server/{id}/service/restart:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -899,7 +1167,7 @@ paths:
|
|||||||
summary: Restart a Windows service
|
summary: Restart a Windows service
|
||||||
tags:
|
tags:
|
||||||
- Service Control
|
- Service Control
|
||||||
/v1/service-control/start:
|
/server/{id}/service/start:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -953,7 +1221,7 @@ paths:
|
|||||||
summary: Start a Windows service
|
summary: Start a Windows service
|
||||||
tags:
|
tags:
|
||||||
- Service Control
|
- Service Control
|
||||||
/v1/service-control/stop:
|
/server/{id}/service/stop:
|
||||||
post:
|
post:
|
||||||
consumes:
|
consumes:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -1007,7 +1275,7 @@ paths:
|
|||||||
summary: Stop a Windows service
|
summary: Stop a Windows service
|
||||||
tags:
|
tags:
|
||||||
- Service Control
|
- Service Control
|
||||||
/v1/state-history:
|
/state-history:
|
||||||
get:
|
get:
|
||||||
description: Return StateHistorys
|
description: Return StateHistorys
|
||||||
responses:
|
responses:
|
||||||
@@ -1020,7 +1288,7 @@ paths:
|
|||||||
summary: Return StateHistorys
|
summary: Return StateHistorys
|
||||||
tags:
|
tags:
|
||||||
- StateHistory
|
- StateHistory
|
||||||
/v1/state-history/statistics:
|
/state-history/statistics:
|
||||||
get:
|
get:
|
||||||
description: Return StateHistorys
|
description: Return StateHistorys
|
||||||
responses:
|
responses:
|
||||||
@@ -1033,8 +1301,136 @@ paths:
|
|||||||
summary: Return StateHistorys
|
summary: Return StateHistorys
|
||||||
tags:
|
tags:
|
||||||
- StateHistory
|
- StateHistory
|
||||||
|
/steam2fa/{id}:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Get a specific Steam 2FA authentication request by ID
|
||||||
|
parameters:
|
||||||
|
- description: 2FA Request ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.Steam2FARequest'
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
summary: Get 2FA request
|
||||||
|
tags:
|
||||||
|
- Steam 2FA
|
||||||
|
/steam2fa/{id}/cancel:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Cancel a Steam 2FA authentication request
|
||||||
|
parameters:
|
||||||
|
- description: 2FA Request ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.Steam2FARequest'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
summary: Cancel 2FA request
|
||||||
|
tags:
|
||||||
|
- Steam 2FA
|
||||||
|
/steam2fa/{id}/complete:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Mark a Steam 2FA authentication request as completed
|
||||||
|
parameters:
|
||||||
|
- description: 2FA Request ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/model.Steam2FARequest'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
summary: Complete 2FA request
|
||||||
|
tags:
|
||||||
|
- Steam 2FA
|
||||||
|
/steam2fa/pending:
|
||||||
|
get:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Get all pending Steam 2FA authentication requests
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/model.Steam2FARequest'
|
||||||
|
type: array
|
||||||
|
"500":
|
||||||
|
description: Internal Server Error
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/error_handler.ErrorResponse'
|
||||||
|
summary: Get pending 2FA requests
|
||||||
|
tags:
|
||||||
|
- Steam 2FA
|
||||||
|
/system/health:
|
||||||
|
get:
|
||||||
|
description: Return service control status
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
summary: Return service control status
|
||||||
|
tags:
|
||||||
|
- system
|
||||||
schemes:
|
schemes:
|
||||||
- http
|
|
||||||
- https
|
- https
|
||||||
securityDefinitions:
|
securityDefinitions:
|
||||||
BearerAuth:
|
BearerAuth:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"acc-server-manager/local/model"
|
"acc-server-manager/local/model"
|
||||||
"acc-server-manager/local/utl/jwt"
|
"acc-server-manager/local/utl/jwt"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -18,8 +19,16 @@ func GenerateTestToken() (string, error) {
|
|||||||
RoleID: uuid.New(),
|
RoleID: uuid.New(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use the environment JWT_SECRET for consistency with middleware
|
||||||
|
testSecret := os.Getenv("JWT_SECRET")
|
||||||
|
if testSecret == "" {
|
||||||
|
// Fallback to a test secret if env var is not set
|
||||||
|
testSecret = "test-secret-that-is-at-least-32-bytes-long-for-security"
|
||||||
|
}
|
||||||
|
jwtHandler := jwt.NewJWTHandler(testSecret)
|
||||||
|
|
||||||
// Generate JWT token
|
// Generate JWT token
|
||||||
token, err := jwt.GenerateToken(user)
|
token, err := jwtHandler.GenerateToken(user.ID.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to generate test token: %w", err)
|
return "", fmt.Errorf("failed to generate test token: %w", err)
|
||||||
}
|
}
|
||||||
@@ -39,6 +48,14 @@ func MustGenerateTestToken() string {
|
|||||||
|
|
||||||
// GenerateTestTokenWithExpiry creates a JWT token with a specific expiry time
|
// GenerateTestTokenWithExpiry creates a JWT token with a specific expiry time
|
||||||
func GenerateTestTokenWithExpiry(expiryTime time.Time) (string, error) {
|
func GenerateTestTokenWithExpiry(expiryTime time.Time) (string, error) {
|
||||||
|
// Use the environment JWT_SECRET for consistency with middleware
|
||||||
|
testSecret := os.Getenv("JWT_SECRET")
|
||||||
|
if testSecret == "" {
|
||||||
|
// Fallback to a test secret if env var is not set
|
||||||
|
testSecret = "test-secret-that-is-at-least-32-bytes-long-for-security"
|
||||||
|
}
|
||||||
|
jwtHandler := jwt.NewJWTHandler(testSecret)
|
||||||
|
|
||||||
// Create test user
|
// Create test user
|
||||||
user := &model.User{
|
user := &model.User{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
@@ -47,7 +64,7 @@ func GenerateTestTokenWithExpiry(expiryTime time.Time) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate JWT token with custom expiry
|
// Generate JWT token with custom expiry
|
||||||
token, err := jwt.GenerateTokenWithExpiry(user, expiryTime)
|
token, err := jwtHandler.GenerateTokenWithExpiry(user, expiryTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to generate test token with expiry: %w", err)
|
return "", fmt.Errorf("failed to generate test token with expiry: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ func (m *MockStateHistoryRepository) GetSessionTypes(ctx context.Context, filter
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Group by session type
|
// Group by session type
|
||||||
sessionMap := make(map[string]map[string]bool) // session -> sessionID -> bool
|
sessionMap := make(map[model.TrackSession]map[string]bool) // session -> sessionID -> bool
|
||||||
for _, entry := range filteredEntries {
|
for _, entry := range filteredEntries {
|
||||||
if sessionMap[entry.Session] == nil {
|
if sessionMap[entry.Session] == nil {
|
||||||
sessionMap[entry.Session] = make(map[string]bool)
|
sessionMap[entry.Session] = make(map[string]bool)
|
||||||
@@ -360,7 +360,7 @@ func (m *MockStateHistoryRepository) GetRecentSessions(ctx context.Context, filt
|
|||||||
if maxPlayers > 0 {
|
if maxPlayers > 0 {
|
||||||
duration := int(maxDate.Sub(minDate).Minutes())
|
duration := int(maxDate.Sub(minDate).Minutes())
|
||||||
recentSessions = append(recentSessions, model.RecentSession{
|
recentSessions = append(recentSessions, model.RecentSession{
|
||||||
ID: uint(count + 1),
|
ID: entries[0].SessionID,
|
||||||
Date: minDate.Format("2006-01-02 15:04:05"),
|
Date: minDate.Format("2006-01-02 15:04:05"),
|
||||||
Type: entries[0].Session,
|
Type: entries[0].Session,
|
||||||
Track: entries[0].Track,
|
Track: entries[0].Track,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package tests
|
|||||||
import (
|
import (
|
||||||
"acc-server-manager/local/model"
|
"acc-server-manager/local/model"
|
||||||
"acc-server-manager/local/utl/configs"
|
"acc-server-manager/local/utl/configs"
|
||||||
"acc-server-manager/local/utl/jwt"
|
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -52,7 +51,6 @@ func SetTestEnv() {
|
|||||||
os.Setenv("TESTING_ENV", "true") // Used to bypass
|
os.Setenv("TESTING_ENV", "true") // Used to bypass
|
||||||
|
|
||||||
configs.Init()
|
configs.Init()
|
||||||
jwt.Init()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTestHelper creates a new test helper with in-memory database
|
// NewTestHelper creates a new test helper with in-memory database
|
||||||
|
|||||||
18
tests/testdata/state_history_data.go
vendored
18
tests/testdata/state_history_data.go
vendored
@@ -22,7 +22,7 @@ func NewStateHistoryTestData(serverID uuid.UUID) *StateHistoryTestData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateStateHistory creates a basic state history entry
|
// CreateStateHistory creates a basic state history entry
|
||||||
func (td *StateHistoryTestData) CreateStateHistory(session string, track string, playerCount int, sessionID uuid.UUID) model.StateHistory {
|
func (td *StateHistoryTestData) CreateStateHistory(session model.TrackSession, track string, playerCount int, sessionID uuid.UUID) model.StateHistory {
|
||||||
return model.StateHistory{
|
return model.StateHistory{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
ServerID: td.ServerID,
|
ServerID: td.ServerID,
|
||||||
@@ -37,7 +37,7 @@ func (td *StateHistoryTestData) CreateStateHistory(session string, track string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateMultipleEntries creates multiple state history entries for the same session
|
// CreateMultipleEntries creates multiple state history entries for the same session
|
||||||
func (td *StateHistoryTestData) CreateMultipleEntries(session string, track string, playerCounts []int) []model.StateHistory {
|
func (td *StateHistoryTestData) CreateMultipleEntries(session model.TrackSession, track string, playerCounts []int) []model.StateHistory {
|
||||||
sessionID := uuid.New()
|
sessionID := uuid.New()
|
||||||
var entries []model.StateHistory
|
var entries []model.StateHistory
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ func CreateBasicFilter(serverID string) *model.StateHistoryFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateFilterWithSession creates a filter with session type
|
// CreateFilterWithSession creates a filter with session type
|
||||||
func CreateFilterWithSession(serverID string, session string) *model.StateHistoryFilter {
|
func CreateFilterWithSession(serverID string, session model.TrackSession) *model.StateHistoryFilter {
|
||||||
return &model.StateHistoryFilter{
|
return &model.StateHistoryFilter{
|
||||||
ServerBasedFilter: model.ServerBasedFilter{
|
ServerBasedFilter: model.ServerBasedFilter{
|
||||||
ServerID: serverID,
|
ServerID: serverID,
|
||||||
@@ -97,13 +97,13 @@ var SampleLogLines = []string{
|
|||||||
|
|
||||||
// ExpectedSessionChanges represents the expected session changes from parsing the sample log lines
|
// ExpectedSessionChanges represents the expected session changes from parsing the sample log lines
|
||||||
var ExpectedSessionChanges = []struct {
|
var ExpectedSessionChanges = []struct {
|
||||||
From string
|
From model.TrackSession
|
||||||
To string
|
To model.TrackSession
|
||||||
}{
|
}{
|
||||||
{"NONE", "PRACTICE"},
|
{model.SessionUnknown, model.SessionPractice},
|
||||||
{"PRACTICE", "QUALIFY"},
|
{model.SessionPractice, model.SessionQualify},
|
||||||
{"QUALIFY", "RACE"},
|
{model.SessionQualify, model.SessionRace},
|
||||||
{"RACE", "NONE"},
|
{model.SessionRace, model.SessionUnknown},
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExpectedPlayerCounts represents the expected player counts from parsing the sample log lines
|
// ExpectedPlayerCounts represents the expected player counts from parsing the sample log lines
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"acc-server-manager/local/middleware"
|
"acc-server-manager/local/middleware"
|
||||||
"acc-server-manager/local/service"
|
"acc-server-manager/local/service"
|
||||||
"acc-server-manager/local/utl/cache"
|
"acc-server-manager/local/utl/cache"
|
||||||
|
"acc-server-manager/local/utl/jwt"
|
||||||
"acc-server-manager/tests"
|
"acc-server-manager/tests"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
@@ -15,9 +17,18 @@ type MockMiddleware struct{}
|
|||||||
// GetTestAuthMiddleware returns a mock auth middleware that can be used in place of the real one
|
// GetTestAuthMiddleware returns a mock auth middleware that can be used in place of the real one
|
||||||
// This works because we're adding real authentication tokens to requests
|
// This works because we're adding real authentication tokens to requests
|
||||||
func GetTestAuthMiddleware(ms *service.MembershipService, cache *cache.InMemoryCache) *middleware.AuthMiddleware {
|
func GetTestAuthMiddleware(ms *service.MembershipService, cache *cache.InMemoryCache) *middleware.AuthMiddleware {
|
||||||
|
// Use environment JWT secrets for consistency with token generation
|
||||||
|
jwtSecret := os.Getenv("JWT_SECRET")
|
||||||
|
if jwtSecret == "" {
|
||||||
|
jwtSecret = "test-secret-that-is-at-least-32-bytes-long-for-security"
|
||||||
|
}
|
||||||
|
|
||||||
|
jwtHandler := jwt.NewJWTHandler(jwtSecret)
|
||||||
|
openJWTHandler := jwt.NewOpenJWTHandler(jwtSecret) // Use same secret for test consistency
|
||||||
|
|
||||||
// Cast our mock to the real type for testing
|
// Cast our mock to the real type for testing
|
||||||
// This is a type-unsafe cast but works for testing because we're using real JWT tokens
|
// This is a type-unsafe cast but works for testing because we're using real JWT tokens
|
||||||
return middleware.NewAuthMiddleware(ms, cache)
|
return middleware.NewAuthMiddleware(ms, cache, jwtHandler, openJWTHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddAuthToRequest adds a valid authentication token to a test request
|
// AddAuthToRequest adds a valid authentication token to a test request
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"acc-server-manager/local/service"
|
"acc-server-manager/local/service"
|
||||||
"acc-server-manager/local/utl/cache"
|
"acc-server-manager/local/utl/cache"
|
||||||
"acc-server-manager/local/utl/common"
|
"acc-server-manager/local/utl/common"
|
||||||
|
"acc-server-manager/local/utl/jwt"
|
||||||
"acc-server-manager/tests"
|
"acc-server-manager/tests"
|
||||||
"acc-server-manager/tests/testdata"
|
"acc-server-manager/tests/testdata"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
@@ -32,13 +34,19 @@ func TestStateHistoryController_GetAll_Success(t *testing.T) {
|
|||||||
stateHistoryService := service.NewStateHistoryService(repo)
|
stateHistoryService := service.NewStateHistoryService(repo)
|
||||||
|
|
||||||
membershipRepo := repository.NewMembershipRepository(helper.DB)
|
membershipRepo := repository.NewMembershipRepository(helper.DB)
|
||||||
membershipService := service.NewMembershipService(membershipRepo)
|
jwtSecret := os.Getenv("JWT_SECRET")
|
||||||
|
if jwtSecret == "" {
|
||||||
|
jwtSecret = "test-secret-that-is-at-least-32-bytes-long-for-security"
|
||||||
|
}
|
||||||
|
jwtHandler := jwt.NewJWTHandler(jwtSecret)
|
||||||
|
openJWTHandler := jwt.NewOpenJWTHandler(jwtSecret)
|
||||||
|
membershipService := service.NewMembershipService(membershipRepo, jwtHandler, openJWTHandler)
|
||||||
|
|
||||||
inMemCache := cache.NewInMemoryCache()
|
inMemCache := cache.NewInMemoryCache()
|
||||||
|
|
||||||
// Insert test data
|
// Insert test data
|
||||||
testData := testdata.NewStateHistoryTestData(helper.TestData.ServerID)
|
testData := testdata.NewStateHistoryTestData(helper.TestData.ServerID)
|
||||||
history := testData.CreateStateHistory("Practice", "spa", 5, uuid.New())
|
history := testData.CreateStateHistory(model.SessionPractice, "spa", 5, uuid.New())
|
||||||
err := repo.Insert(helper.CreateContext(), &history)
|
err := repo.Insert(helper.CreateContext(), &history)
|
||||||
tests.AssertNoError(t, err)
|
tests.AssertNoError(t, err)
|
||||||
|
|
||||||
@@ -70,7 +78,7 @@ func TestStateHistoryController_GetAll_Success(t *testing.T) {
|
|||||||
err = json.Unmarshal(body, &result)
|
err = json.Unmarshal(body, &result)
|
||||||
tests.AssertNoError(t, err)
|
tests.AssertNoError(t, err)
|
||||||
tests.AssertEqual(t, 1, len(result))
|
tests.AssertEqual(t, 1, len(result))
|
||||||
tests.AssertEqual(t, "Practice", result[0].Session)
|
tests.AssertEqual(t, model.SessionPractice, result[0].Session)
|
||||||
tests.AssertEqual(t, 5, result[0].PlayerCount)
|
tests.AssertEqual(t, 5, result[0].PlayerCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,15 +94,21 @@ func TestStateHistoryController_GetAll_WithSessionFilter(t *testing.T) {
|
|||||||
stateHistoryService := service.NewStateHistoryService(repo)
|
stateHistoryService := service.NewStateHistoryService(repo)
|
||||||
|
|
||||||
membershipRepo := repository.NewMembershipRepository(helper.DB)
|
membershipRepo := repository.NewMembershipRepository(helper.DB)
|
||||||
membershipService := service.NewMembershipService(membershipRepo)
|
jwtSecret := os.Getenv("JWT_SECRET")
|
||||||
|
if jwtSecret == "" {
|
||||||
|
jwtSecret = "test-secret-that-is-at-least-32-bytes-long-for-security"
|
||||||
|
}
|
||||||
|
jwtHandler := jwt.NewJWTHandler(jwtSecret)
|
||||||
|
openJWTHandler := jwt.NewOpenJWTHandler(jwtSecret)
|
||||||
|
membershipService := service.NewMembershipService(membershipRepo, jwtHandler, openJWTHandler)
|
||||||
|
|
||||||
inMemCache := cache.NewInMemoryCache()
|
inMemCache := cache.NewInMemoryCache()
|
||||||
|
|
||||||
// Insert test data with different sessions
|
// Insert test data with different sessions
|
||||||
testData := testdata.NewStateHistoryTestData(helper.TestData.ServerID)
|
testData := testdata.NewStateHistoryTestData(helper.TestData.ServerID)
|
||||||
|
|
||||||
practiceHistory := testData.CreateStateHistory("Practice", "spa", 5, uuid.New())
|
practiceHistory := testData.CreateStateHistory(model.SessionPractice, "spa", 5, uuid.New())
|
||||||
raceHistory := testData.CreateStateHistory("Race", "spa", 10, uuid.New())
|
raceHistory := testData.CreateStateHistory(model.SessionRace, "spa", 10, uuid.New())
|
||||||
|
|
||||||
err := repo.Insert(helper.CreateContext(), &practiceHistory)
|
err := repo.Insert(helper.CreateContext(), &practiceHistory)
|
||||||
tests.AssertNoError(t, err)
|
tests.AssertNoError(t, err)
|
||||||
@@ -110,7 +124,7 @@ func TestStateHistoryController_GetAll_WithSessionFilter(t *testing.T) {
|
|||||||
controller.NewStateHistoryController(stateHistoryService, routeGroups, GetTestAuthMiddleware(membershipService, inMemCache))
|
controller.NewStateHistoryController(stateHistoryService, routeGroups, GetTestAuthMiddleware(membershipService, inMemCache))
|
||||||
|
|
||||||
// Create request with session filter and authentication
|
// Create request with session filter and authentication
|
||||||
req := httptest.NewRequest("GET", fmt.Sprintf("/api/v1/state-history?id=%s&session=Race", helper.TestData.ServerID.String()), nil)
|
req := httptest.NewRequest("GET", fmt.Sprintf("/api/v1/state-history?id=%s&session=R", helper.TestData.ServerID.String()), nil)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Authorization", "Bearer "+tests.MustGenerateTestToken())
|
req.Header.Set("Authorization", "Bearer "+tests.MustGenerateTestToken())
|
||||||
|
|
||||||
@@ -129,7 +143,7 @@ func TestStateHistoryController_GetAll_WithSessionFilter(t *testing.T) {
|
|||||||
err = json.Unmarshal(body, &result)
|
err = json.Unmarshal(body, &result)
|
||||||
tests.AssertNoError(t, err)
|
tests.AssertNoError(t, err)
|
||||||
tests.AssertEqual(t, 1, len(result))
|
tests.AssertEqual(t, 1, len(result))
|
||||||
tests.AssertEqual(t, "Race", result[0].Session)
|
tests.AssertEqual(t, model.SessionRace, result[0].Session)
|
||||||
tests.AssertEqual(t, 10, result[0].PlayerCount)
|
tests.AssertEqual(t, 10, result[0].PlayerCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +159,13 @@ func TestStateHistoryController_GetAll_EmptyResult(t *testing.T) {
|
|||||||
stateHistoryService := service.NewStateHistoryService(repo)
|
stateHistoryService := service.NewStateHistoryService(repo)
|
||||||
|
|
||||||
membershipRepo := repository.NewMembershipRepository(helper.DB)
|
membershipRepo := repository.NewMembershipRepository(helper.DB)
|
||||||
membershipService := service.NewMembershipService(membershipRepo)
|
jwtSecret := os.Getenv("JWT_SECRET")
|
||||||
|
if jwtSecret == "" {
|
||||||
|
jwtSecret = "test-secret-that-is-at-least-32-bytes-long-for-security"
|
||||||
|
}
|
||||||
|
jwtHandler := jwt.NewJWTHandler(jwtSecret)
|
||||||
|
openJWTHandler := jwt.NewOpenJWTHandler(jwtSecret)
|
||||||
|
membershipService := service.NewMembershipService(membershipRepo, jwtHandler, openJWTHandler)
|
||||||
|
|
||||||
inMemCache := cache.NewInMemoryCache()
|
inMemCache := cache.NewInMemoryCache()
|
||||||
|
|
||||||
@@ -185,7 +205,13 @@ func TestStateHistoryController_GetStatistics_Success(t *testing.T) {
|
|||||||
stateHistoryService := service.NewStateHistoryService(repo)
|
stateHistoryService := service.NewStateHistoryService(repo)
|
||||||
|
|
||||||
membershipRepo := repository.NewMembershipRepository(helper.DB)
|
membershipRepo := repository.NewMembershipRepository(helper.DB)
|
||||||
membershipService := service.NewMembershipService(membershipRepo)
|
jwtSecret := os.Getenv("JWT_SECRET")
|
||||||
|
if jwtSecret == "" {
|
||||||
|
jwtSecret = "test-secret-that-is-at-least-32-bytes-long-for-security"
|
||||||
|
}
|
||||||
|
jwtHandler := jwt.NewJWTHandler(jwtSecret)
|
||||||
|
openJWTHandler := jwt.NewOpenJWTHandler(jwtSecret)
|
||||||
|
membershipService := service.NewMembershipService(membershipRepo, jwtHandler, openJWTHandler)
|
||||||
|
|
||||||
inMemCache := cache.NewInMemoryCache()
|
inMemCache := cache.NewInMemoryCache()
|
||||||
|
|
||||||
@@ -194,7 +220,7 @@ func TestStateHistoryController_GetStatistics_Success(t *testing.T) {
|
|||||||
|
|
||||||
// Create entries with varying player counts
|
// Create entries with varying player counts
|
||||||
playerCounts := []int{5, 10, 15, 20, 25}
|
playerCounts := []int{5, 10, 15, 20, 25}
|
||||||
entries := testData.CreateMultipleEntries("Race", "spa", playerCounts)
|
entries := testData.CreateMultipleEntries(model.SessionRace, "spa", playerCounts)
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
err := repo.Insert(helper.CreateContext(), &entry)
|
err := repo.Insert(helper.CreateContext(), &entry)
|
||||||
@@ -262,7 +288,13 @@ func TestStateHistoryController_GetStatistics_NoData(t *testing.T) {
|
|||||||
stateHistoryService := service.NewStateHistoryService(repo)
|
stateHistoryService := service.NewStateHistoryService(repo)
|
||||||
|
|
||||||
membershipRepo := repository.NewMembershipRepository(helper.DB)
|
membershipRepo := repository.NewMembershipRepository(helper.DB)
|
||||||
membershipService := service.NewMembershipService(membershipRepo)
|
jwtSecret := os.Getenv("JWT_SECRET")
|
||||||
|
if jwtSecret == "" {
|
||||||
|
jwtSecret = "test-secret-that-is-at-least-32-bytes-long-for-security"
|
||||||
|
}
|
||||||
|
jwtHandler := jwt.NewJWTHandler(jwtSecret)
|
||||||
|
openJWTHandler := jwt.NewOpenJWTHandler(jwtSecret)
|
||||||
|
membershipService := service.NewMembershipService(membershipRepo, jwtHandler, openJWTHandler)
|
||||||
|
|
||||||
inMemCache := cache.NewInMemoryCache()
|
inMemCache := cache.NewInMemoryCache()
|
||||||
|
|
||||||
@@ -321,7 +353,13 @@ func TestStateHistoryController_GetStatistics_InvalidQueryParams(t *testing.T) {
|
|||||||
stateHistoryService := service.NewStateHistoryService(repo)
|
stateHistoryService := service.NewStateHistoryService(repo)
|
||||||
|
|
||||||
membershipRepo := repository.NewMembershipRepository(helper.DB)
|
membershipRepo := repository.NewMembershipRepository(helper.DB)
|
||||||
membershipService := service.NewMembershipService(membershipRepo)
|
jwtSecret := os.Getenv("JWT_SECRET")
|
||||||
|
if jwtSecret == "" {
|
||||||
|
jwtSecret = "test-secret-that-is-at-least-32-bytes-long-for-security"
|
||||||
|
}
|
||||||
|
jwtHandler := jwt.NewJWTHandler(jwtSecret)
|
||||||
|
openJWTHandler := jwt.NewOpenJWTHandler(jwtSecret)
|
||||||
|
membershipService := service.NewMembershipService(membershipRepo, jwtHandler, openJWTHandler)
|
||||||
|
|
||||||
inMemCache := cache.NewInMemoryCache()
|
inMemCache := cache.NewInMemoryCache()
|
||||||
|
|
||||||
@@ -365,7 +403,13 @@ func TestStateHistoryController_HTTPMethods(t *testing.T) {
|
|||||||
stateHistoryService := service.NewStateHistoryService(repo)
|
stateHistoryService := service.NewStateHistoryService(repo)
|
||||||
|
|
||||||
membershipRepo := repository.NewMembershipRepository(helper.DB)
|
membershipRepo := repository.NewMembershipRepository(helper.DB)
|
||||||
membershipService := service.NewMembershipService(membershipRepo)
|
jwtSecret := os.Getenv("JWT_SECRET")
|
||||||
|
if jwtSecret == "" {
|
||||||
|
jwtSecret = "test-secret-that-is-at-least-32-bytes-long-for-security"
|
||||||
|
}
|
||||||
|
jwtHandler := jwt.NewJWTHandler(jwtSecret)
|
||||||
|
openJWTHandler := jwt.NewOpenJWTHandler(jwtSecret)
|
||||||
|
membershipService := service.NewMembershipService(membershipRepo, jwtHandler, openJWTHandler)
|
||||||
|
|
||||||
inMemCache := cache.NewInMemoryCache()
|
inMemCache := cache.NewInMemoryCache()
|
||||||
|
|
||||||
@@ -419,13 +463,19 @@ func TestStateHistoryController_ContentType(t *testing.T) {
|
|||||||
stateHistoryService := service.NewStateHistoryService(repo)
|
stateHistoryService := service.NewStateHistoryService(repo)
|
||||||
|
|
||||||
membershipRepo := repository.NewMembershipRepository(helper.DB)
|
membershipRepo := repository.NewMembershipRepository(helper.DB)
|
||||||
membershipService := service.NewMembershipService(membershipRepo)
|
jwtSecret := os.Getenv("JWT_SECRET")
|
||||||
|
if jwtSecret == "" {
|
||||||
|
jwtSecret = "test-secret-that-is-at-least-32-bytes-long-for-security"
|
||||||
|
}
|
||||||
|
jwtHandler := jwt.NewJWTHandler(jwtSecret)
|
||||||
|
openJWTHandler := jwt.NewOpenJWTHandler(jwtSecret)
|
||||||
|
membershipService := service.NewMembershipService(membershipRepo, jwtHandler, openJWTHandler)
|
||||||
|
|
||||||
inMemCache := cache.NewInMemoryCache()
|
inMemCache := cache.NewInMemoryCache()
|
||||||
|
|
||||||
// Insert test data
|
// Insert test data
|
||||||
testData := testdata.NewStateHistoryTestData(helper.TestData.ServerID)
|
testData := testdata.NewStateHistoryTestData(helper.TestData.ServerID)
|
||||||
history := testData.CreateStateHistory("Practice", "spa", 5, uuid.New())
|
history := testData.CreateStateHistory(model.SessionPractice, "spa", 5, uuid.New())
|
||||||
err := repo.Insert(helper.CreateContext(), &history)
|
err := repo.Insert(helper.CreateContext(), &history)
|
||||||
tests.AssertNoError(t, err)
|
tests.AssertNoError(t, err)
|
||||||
|
|
||||||
@@ -481,7 +531,13 @@ func TestStateHistoryController_ResponseStructure(t *testing.T) {
|
|||||||
stateHistoryService := service.NewStateHistoryService(repo)
|
stateHistoryService := service.NewStateHistoryService(repo)
|
||||||
|
|
||||||
membershipRepo := repository.NewMembershipRepository(helper.DB)
|
membershipRepo := repository.NewMembershipRepository(helper.DB)
|
||||||
membershipService := service.NewMembershipService(membershipRepo)
|
jwtSecret := os.Getenv("JWT_SECRET")
|
||||||
|
if jwtSecret == "" {
|
||||||
|
jwtSecret = "test-secret-that-is-at-least-32-bytes-long-for-security"
|
||||||
|
}
|
||||||
|
jwtHandler := jwt.NewJWTHandler(jwtSecret)
|
||||||
|
openJWTHandler := jwt.NewOpenJWTHandler(jwtSecret)
|
||||||
|
membershipService := service.NewMembershipService(membershipRepo, jwtHandler, openJWTHandler)
|
||||||
|
|
||||||
inMemCache := cache.NewInMemoryCache()
|
inMemCache := cache.NewInMemoryCache()
|
||||||
|
|
||||||
@@ -495,7 +551,7 @@ func TestStateHistoryController_ResponseStructure(t *testing.T) {
|
|||||||
|
|
||||||
// Insert test data
|
// Insert test data
|
||||||
testData := testdata.NewStateHistoryTestData(helper.TestData.ServerID)
|
testData := testdata.NewStateHistoryTestData(helper.TestData.ServerID)
|
||||||
history := testData.CreateStateHistory("Practice", "spa", 5, uuid.New())
|
history := testData.CreateStateHistory(model.SessionPractice, "spa", 5, uuid.New())
|
||||||
err := repo.Insert(helper.CreateContext(), &history)
|
err := repo.Insert(helper.CreateContext(), &history)
|
||||||
tests.AssertNoError(t, err)
|
tests.AssertNoError(t, err)
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func TestStateHistoryRepository_Insert_Success(t *testing.T) {
|
|||||||
|
|
||||||
// Create test data
|
// Create test data
|
||||||
testData := testdata.NewStateHistoryTestData(helper.TestData.ServerID)
|
testData := testdata.NewStateHistoryTestData(helper.TestData.ServerID)
|
||||||
history := testData.CreateStateHistory("Practice", "spa", 5, uuid.New())
|
history := testData.CreateStateHistory(model.SessionPractice, "spa", 5, uuid.New())
|
||||||
|
|
||||||
// Test Insert
|
// Test Insert
|
||||||
err := repo.Insert(ctx, &history)
|
err := repo.Insert(ctx, &history)
|
||||||
@@ -65,7 +65,7 @@ func TestStateHistoryRepository_GetAll_Success(t *testing.T) {
|
|||||||
|
|
||||||
// Insert multiple entries
|
// Insert multiple entries
|
||||||
playerCounts := []int{0, 5, 10, 15, 10, 5, 0}
|
playerCounts := []int{0, 5, 10, 15, 10, 5, 0}
|
||||||
entries := testData.CreateMultipleEntries("Practice", "spa", playerCounts)
|
entries := testData.CreateMultipleEntries(model.SessionPractice, "spa", playerCounts)
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
err := repo.Insert(ctx, &entry)
|
err := repo.Insert(ctx, &entry)
|
||||||
@@ -101,8 +101,8 @@ func TestStateHistoryRepository_GetAll_WithFilter(t *testing.T) {
|
|||||||
// Create test data with different sessions
|
// Create test data with different sessions
|
||||||
testData := testdata.NewStateHistoryTestData(helper.TestData.ServerID)
|
testData := testdata.NewStateHistoryTestData(helper.TestData.ServerID)
|
||||||
|
|
||||||
practiceHistory := testData.CreateStateHistory("Practice", "spa", 5, uuid.New())
|
practiceHistory := testData.CreateStateHistory(model.SessionPractice, "spa", 5, uuid.New())
|
||||||
raceHistory := testData.CreateStateHistory("Race", "spa", 15, uuid.New())
|
raceHistory := testData.CreateStateHistory(model.SessionRace, "spa", 15, uuid.New())
|
||||||
|
|
||||||
// Insert both
|
// Insert both
|
||||||
err := repo.Insert(ctx, &practiceHistory)
|
err := repo.Insert(ctx, &practiceHistory)
|
||||||
@@ -111,13 +111,13 @@ func TestStateHistoryRepository_GetAll_WithFilter(t *testing.T) {
|
|||||||
tests.AssertNoError(t, err)
|
tests.AssertNoError(t, err)
|
||||||
|
|
||||||
// Test GetAll with session filter
|
// Test GetAll with session filter
|
||||||
filter := testdata.CreateFilterWithSession(helper.TestData.ServerID.String(), "Race")
|
filter := testdata.CreateFilterWithSession(helper.TestData.ServerID.String(), model.SessionRace)
|
||||||
result, err := repo.GetAll(ctx, filter)
|
result, err := repo.GetAll(ctx, filter)
|
||||||
|
|
||||||
tests.AssertNoError(t, err)
|
tests.AssertNoError(t, err)
|
||||||
tests.AssertNotNil(t, result)
|
tests.AssertNotNil(t, result)
|
||||||
tests.AssertEqual(t, 1, len(*result))
|
tests.AssertEqual(t, 1, len(*result))
|
||||||
tests.AssertEqual(t, "Race", (*result)[0].Session)
|
tests.AssertEqual(t, model.SessionRace, (*result)[0].Session)
|
||||||
tests.AssertEqual(t, 15, (*result)[0].PlayerCount)
|
tests.AssertEqual(t, 15, (*result)[0].PlayerCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,8 +145,8 @@ func TestStateHistoryRepository_GetLastSessionID_Success(t *testing.T) {
|
|||||||
sessionID1 := uuid.New()
|
sessionID1 := uuid.New()
|
||||||
sessionID2 := uuid.New()
|
sessionID2 := uuid.New()
|
||||||
|
|
||||||
history1 := testData.CreateStateHistory("Practice", "spa", 5, sessionID1)
|
history1 := testData.CreateStateHistory(model.SessionPractice, "spa", 5, sessionID1)
|
||||||
history2 := testData.CreateStateHistory("Race", "spa", 10, sessionID2)
|
history2 := testData.CreateStateHistory(model.SessionRace, "spa", 10, sessionID2)
|
||||||
|
|
||||||
// Insert with a small delay to ensure ordering
|
// Insert with a small delay to ensure ordering
|
||||||
err := repo.Insert(ctx, &history1)
|
err := repo.Insert(ctx, &history1)
|
||||||
@@ -217,7 +217,7 @@ func TestStateHistoryRepository_GetSummaryStats_Success(t *testing.T) {
|
|||||||
sessionID2 := uuid.New()
|
sessionID2 := uuid.New()
|
||||||
|
|
||||||
// Practice session: 5, 10, 15 players
|
// Practice session: 5, 10, 15 players
|
||||||
practiceEntries := testData.CreateMultipleEntries("Practice", "spa", []int{5, 10, 15})
|
practiceEntries := testData.CreateMultipleEntries(model.SessionPractice, "spa", []int{5, 10, 15})
|
||||||
for i := range practiceEntries {
|
for i := range practiceEntries {
|
||||||
practiceEntries[i].SessionID = sessionID1
|
practiceEntries[i].SessionID = sessionID1
|
||||||
err := repo.Insert(ctx, &practiceEntries[i])
|
err := repo.Insert(ctx, &practiceEntries[i])
|
||||||
@@ -225,7 +225,7 @@ func TestStateHistoryRepository_GetSummaryStats_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Race session: 20, 25, 30 players
|
// Race session: 20, 25, 30 players
|
||||||
raceEntries := testData.CreateMultipleEntries("Race", "spa", []int{20, 25, 30})
|
raceEntries := testData.CreateMultipleEntries(model.SessionRace, "spa", []int{20, 25, 30})
|
||||||
for i := range raceEntries {
|
for i := range raceEntries {
|
||||||
raceEntries[i].SessionID = sessionID2
|
raceEntries[i].SessionID = sessionID2
|
||||||
err := repo.Insert(ctx, &raceEntries[i])
|
err := repo.Insert(ctx, &raceEntries[i])
|
||||||
@@ -305,7 +305,7 @@ func TestStateHistoryRepository_GetTotalPlaytime_Success(t *testing.T) {
|
|||||||
{
|
{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
ServerID: helper.TestData.ServerID,
|
ServerID: helper.TestData.ServerID,
|
||||||
Session: "Practice",
|
Session: model.SessionPractice,
|
||||||
Track: "spa",
|
Track: "spa",
|
||||||
PlayerCount: 5,
|
PlayerCount: 5,
|
||||||
DateCreated: baseTime,
|
DateCreated: baseTime,
|
||||||
@@ -316,7 +316,7 @@ func TestStateHistoryRepository_GetTotalPlaytime_Success(t *testing.T) {
|
|||||||
{
|
{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
ServerID: helper.TestData.ServerID,
|
ServerID: helper.TestData.ServerID,
|
||||||
Session: "Practice",
|
Session: model.SessionPractice,
|
||||||
Track: "spa",
|
Track: "spa",
|
||||||
PlayerCount: 10,
|
PlayerCount: 10,
|
||||||
DateCreated: baseTime.Add(30 * time.Minute),
|
DateCreated: baseTime.Add(30 * time.Minute),
|
||||||
@@ -327,7 +327,7 @@ func TestStateHistoryRepository_GetTotalPlaytime_Success(t *testing.T) {
|
|||||||
{
|
{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
ServerID: helper.TestData.ServerID,
|
ServerID: helper.TestData.ServerID,
|
||||||
Session: "Practice",
|
Session: model.SessionPractice,
|
||||||
Track: "spa",
|
Track: "spa",
|
||||||
PlayerCount: 8,
|
PlayerCount: 8,
|
||||||
DateCreated: baseTime.Add(60 * time.Minute),
|
DateCreated: baseTime.Add(60 * time.Minute),
|
||||||
@@ -391,7 +391,7 @@ func TestStateHistoryRepository_ConcurrentOperations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create and insert initial entry to ensure table exists and is properly set up
|
// Create and insert initial entry to ensure table exists and is properly set up
|
||||||
initialHistory := testData.CreateStateHistory("Practice", "spa", 5, uuid.New())
|
initialHistory := testData.CreateStateHistory(model.SessionPractice, "spa", 5, uuid.New())
|
||||||
err := repo.Insert(ctx, &initialHistory)
|
err := repo.Insert(ctx, &initialHistory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to insert initial record: %v", err)
|
t.Fatalf("Failed to insert initial record: %v", err)
|
||||||
@@ -404,7 +404,7 @@ func TestStateHistoryRepository_ConcurrentOperations(t *testing.T) {
|
|||||||
defer func() {
|
defer func() {
|
||||||
done <- true
|
done <- true
|
||||||
}()
|
}()
|
||||||
history := testData.CreateStateHistory("Practice", "spa", 5, uuid.New())
|
history := testData.CreateStateHistory(model.SessionPractice, "spa", 5, uuid.New())
|
||||||
err := repo.Insert(ctx, &history)
|
err := repo.Insert(ctx, &history)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Logf("Insert error: %v", err)
|
t.Logf("Insert error: %v", err)
|
||||||
@@ -462,7 +462,7 @@ func TestStateHistoryRepository_FilterEdgeCases(t *testing.T) {
|
|||||||
|
|
||||||
// Insert a test record to ensure the table is properly set up
|
// Insert a test record to ensure the table is properly set up
|
||||||
testData := testdata.NewStateHistoryTestData(helper.TestData.ServerID)
|
testData := testdata.NewStateHistoryTestData(helper.TestData.ServerID)
|
||||||
history := testData.CreateStateHistory("Practice", "spa", 5, uuid.New())
|
history := testData.CreateStateHistory(model.SessionPractice, "spa", 5, uuid.New())
|
||||||
err := repo.Insert(ctx, &history)
|
err := repo.Insert(ctx, &history)
|
||||||
tests.AssertNoError(t, err)
|
tests.AssertNoError(t, err)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"acc-server-manager/local/utl/jwt"
|
"acc-server-manager/local/utl/jwt"
|
||||||
"acc-server-manager/local/utl/password"
|
"acc-server-manager/local/utl/password"
|
||||||
"acc-server-manager/tests"
|
"acc-server-manager/tests"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -15,6 +16,8 @@ func TestJWT_GenerateAndValidateToken(t *testing.T) {
|
|||||||
helper := tests.NewTestHelper(t)
|
helper := tests.NewTestHelper(t)
|
||||||
defer helper.Cleanup()
|
defer helper.Cleanup()
|
||||||
|
|
||||||
|
jwtHandler := jwt.NewJWTHandler(os.Getenv("JWT_SECRET"))
|
||||||
|
|
||||||
// Create test user
|
// Create test user
|
||||||
user := &model.User{
|
user := &model.User{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
@@ -23,7 +26,7 @@ func TestJWT_GenerateAndValidateToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test JWT generation
|
// Test JWT generation
|
||||||
token, err := jwt.GenerateToken(user)
|
token, err := jwtHandler.GenerateToken(user.ID.String())
|
||||||
tests.AssertNoError(t, err)
|
tests.AssertNoError(t, err)
|
||||||
tests.AssertNotNil(t, token)
|
tests.AssertNotNil(t, token)
|
||||||
|
|
||||||
@@ -33,7 +36,7 @@ func TestJWT_GenerateAndValidateToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test JWT validation
|
// Test JWT validation
|
||||||
claims, err := jwt.ValidateToken(token)
|
claims, err := jwtHandler.ValidateToken(token)
|
||||||
tests.AssertNoError(t, err)
|
tests.AssertNoError(t, err)
|
||||||
tests.AssertNotNil(t, claims)
|
tests.AssertNotNil(t, claims)
|
||||||
tests.AssertEqual(t, user.ID.String(), claims.UserID)
|
tests.AssertEqual(t, user.ID.String(), claims.UserID)
|
||||||
@@ -43,9 +46,10 @@ func TestJWT_ValidateToken_InvalidToken(t *testing.T) {
|
|||||||
// Setup
|
// Setup
|
||||||
helper := tests.NewTestHelper(t)
|
helper := tests.NewTestHelper(t)
|
||||||
defer helper.Cleanup()
|
defer helper.Cleanup()
|
||||||
|
jwtHandler := jwt.NewJWTHandler(os.Getenv("JWT_SECRET"))
|
||||||
|
|
||||||
// Test with invalid token
|
// Test with invalid token
|
||||||
claims, err := jwt.ValidateToken("invalid-token")
|
claims, err := jwtHandler.ValidateToken("invalid-token")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("Expected error for invalid token, got nil")
|
t.Fatal("Expected error for invalid token, got nil")
|
||||||
}
|
}
|
||||||
@@ -59,9 +63,10 @@ func TestJWT_ValidateToken_EmptyToken(t *testing.T) {
|
|||||||
// Setup
|
// Setup
|
||||||
helper := tests.NewTestHelper(t)
|
helper := tests.NewTestHelper(t)
|
||||||
defer helper.Cleanup()
|
defer helper.Cleanup()
|
||||||
|
jwtHandler := jwt.NewJWTHandler(os.Getenv("JWT_SECRET"))
|
||||||
|
|
||||||
// Test with empty token
|
// Test with empty token
|
||||||
claims, err := jwt.ValidateToken("")
|
claims, err := jwtHandler.ValidateToken("")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("Expected error for empty token, got nil")
|
t.Fatal("Expected error for empty token, got nil")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ func TestConfigService_GetEventConfig_ValidFile(t *testing.T) {
|
|||||||
// Verify sessions
|
// Verify sessions
|
||||||
tests.AssertEqual(t, 3, len(eventConfig.Sessions))
|
tests.AssertEqual(t, 3, len(eventConfig.Sessions))
|
||||||
if len(eventConfig.Sessions) > 0 {
|
if len(eventConfig.Sessions) > 0 {
|
||||||
tests.AssertEqual(t, "P", eventConfig.Sessions[0].SessionType)
|
tests.AssertEqual(t, model.SessionPractice, eventConfig.Sessions[0].SessionType)
|
||||||
tests.AssertEqual(t, model.IntString(10), eventConfig.Sessions[0].SessionDurationMinutes)
|
tests.AssertEqual(t, model.IntString(10), eventConfig.Sessions[0].SessionDurationMinutes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func TestStateHistoryService_GetAll_Success(t *testing.T) {
|
|||||||
|
|
||||||
// Insert test data directly into DB
|
// Insert test data directly into DB
|
||||||
testData := testdata.NewStateHistoryTestData(helper.TestData.ServerID)
|
testData := testdata.NewStateHistoryTestData(helper.TestData.ServerID)
|
||||||
history := testData.CreateStateHistory("Practice", "spa", 5, uuid.New())
|
history := testData.CreateStateHistory(model.SessionPractice, "spa", 5, uuid.New())
|
||||||
err := repo.Insert(helper.CreateContext(), &history)
|
err := repo.Insert(helper.CreateContext(), &history)
|
||||||
tests.AssertNoError(t, err)
|
tests.AssertNoError(t, err)
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ func TestStateHistoryService_GetAll_Success(t *testing.T) {
|
|||||||
tests.AssertNoError(t, err)
|
tests.AssertNoError(t, err)
|
||||||
tests.AssertNotNil(t, result)
|
tests.AssertNotNil(t, result)
|
||||||
tests.AssertEqual(t, 1, len(*result))
|
tests.AssertEqual(t, 1, len(*result))
|
||||||
tests.AssertEqual(t, "Practice", (*result)[0].Session)
|
tests.AssertEqual(t, model.SessionPractice, (*result)[0].Session)
|
||||||
tests.AssertEqual(t, 5, (*result)[0].PlayerCount)
|
tests.AssertEqual(t, 5, (*result)[0].PlayerCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,8 +71,8 @@ func TestStateHistoryService_GetAll_WithFilter(t *testing.T) {
|
|||||||
|
|
||||||
// Insert test data with different sessions
|
// Insert test data with different sessions
|
||||||
testData := testdata.NewStateHistoryTestData(helper.TestData.ServerID)
|
testData := testdata.NewStateHistoryTestData(helper.TestData.ServerID)
|
||||||
practiceHistory := testData.CreateStateHistory("Practice", "spa", 5, uuid.New())
|
practiceHistory := testData.CreateStateHistory(model.SessionPractice, "spa", 5, uuid.New())
|
||||||
raceHistory := testData.CreateStateHistory("Race", "spa", 10, uuid.New())
|
raceHistory := testData.CreateStateHistory(model.SessionRace, "spa", 10, uuid.New())
|
||||||
|
|
||||||
err := repo.Insert(helper.CreateContext(), &practiceHistory)
|
err := repo.Insert(helper.CreateContext(), &practiceHistory)
|
||||||
tests.AssertNoError(t, err)
|
tests.AssertNoError(t, err)
|
||||||
@@ -85,13 +85,13 @@ func TestStateHistoryService_GetAll_WithFilter(t *testing.T) {
|
|||||||
defer helper.ReleaseFiberCtx(app, ctx)
|
defer helper.ReleaseFiberCtx(app, ctx)
|
||||||
|
|
||||||
// Test GetAll with session filter
|
// Test GetAll with session filter
|
||||||
filter := testdata.CreateFilterWithSession(helper.TestData.ServerID.String(), "Race")
|
filter := testdata.CreateFilterWithSession(helper.TestData.ServerID.String(), model.SessionRace)
|
||||||
result, err := stateHistoryService.GetAll(ctx, filter)
|
result, err := stateHistoryService.GetAll(ctx, filter)
|
||||||
|
|
||||||
tests.AssertNoError(t, err)
|
tests.AssertNoError(t, err)
|
||||||
tests.AssertNotNil(t, result)
|
tests.AssertNotNil(t, result)
|
||||||
tests.AssertEqual(t, 1, len(*result))
|
tests.AssertEqual(t, 1, len(*result))
|
||||||
tests.AssertEqual(t, "Race", (*result)[0].Session)
|
tests.AssertEqual(t, model.SessionRace, (*result)[0].Session)
|
||||||
tests.AssertEqual(t, 10, (*result)[0].PlayerCount)
|
tests.AssertEqual(t, 10, (*result)[0].PlayerCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +143,7 @@ func TestStateHistoryService_Insert_Success(t *testing.T) {
|
|||||||
|
|
||||||
// Create test data
|
// Create test data
|
||||||
testData := testdata.NewStateHistoryTestData(helper.TestData.ServerID)
|
testData := testdata.NewStateHistoryTestData(helper.TestData.ServerID)
|
||||||
history := testData.CreateStateHistory("Practice", "spa", 5, uuid.New())
|
history := testData.CreateStateHistory(model.SessionPractice, "spa", 5, uuid.New())
|
||||||
|
|
||||||
// Create proper Fiber context
|
// Create proper Fiber context
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
@@ -180,7 +180,7 @@ func TestStateHistoryService_GetLastSessionID_Success(t *testing.T) {
|
|||||||
// Insert test data
|
// Insert test data
|
||||||
testData := testdata.NewStateHistoryTestData(helper.TestData.ServerID)
|
testData := testdata.NewStateHistoryTestData(helper.TestData.ServerID)
|
||||||
sessionID := uuid.New()
|
sessionID := uuid.New()
|
||||||
history := testData.CreateStateHistory("Practice", "spa", 5, sessionID)
|
history := testData.CreateStateHistory(model.SessionPractice, "spa", 5, sessionID)
|
||||||
err := repo.Insert(helper.CreateContext(), &history)
|
err := repo.Insert(helper.CreateContext(), &history)
|
||||||
tests.AssertNoError(t, err)
|
tests.AssertNoError(t, err)
|
||||||
|
|
||||||
@@ -254,7 +254,7 @@ func TestStateHistoryService_GetStatistics_Success(t *testing.T) {
|
|||||||
{
|
{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
ServerID: helper.TestData.ServerID,
|
ServerID: helper.TestData.ServerID,
|
||||||
Session: "Practice",
|
Session: model.SessionPractice,
|
||||||
Track: "spa",
|
Track: "spa",
|
||||||
PlayerCount: 5,
|
PlayerCount: 5,
|
||||||
DateCreated: baseTime,
|
DateCreated: baseTime,
|
||||||
@@ -265,7 +265,7 @@ func TestStateHistoryService_GetStatistics_Success(t *testing.T) {
|
|||||||
{
|
{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
ServerID: helper.TestData.ServerID,
|
ServerID: helper.TestData.ServerID,
|
||||||
Session: "Practice",
|
Session: model.SessionPractice,
|
||||||
Track: "spa",
|
Track: "spa",
|
||||||
PlayerCount: 10,
|
PlayerCount: 10,
|
||||||
DateCreated: baseTime.Add(5 * time.Minute),
|
DateCreated: baseTime.Add(5 * time.Minute),
|
||||||
@@ -276,7 +276,7 @@ func TestStateHistoryService_GetStatistics_Success(t *testing.T) {
|
|||||||
{
|
{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
ServerID: helper.TestData.ServerID,
|
ServerID: helper.TestData.ServerID,
|
||||||
Session: "Race",
|
Session: model.SessionRace,
|
||||||
Track: "spa",
|
Track: "spa",
|
||||||
PlayerCount: 15,
|
PlayerCount: 15,
|
||||||
DateCreated: baseTime.Add(10 * time.Minute),
|
DateCreated: baseTime.Add(10 * time.Minute),
|
||||||
@@ -404,7 +404,7 @@ func TestStateHistoryService_LogParsingWorkflow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify session changes were parsed correctly
|
// Verify session changes were parsed correctly
|
||||||
expectedSessions := []string{"PRACTICE", "QUALIFY", "RACE", "NONE"}
|
expectedSessions := []model.TrackSession{model.SessionPractice, model.SessionQualify, model.SessionRace}
|
||||||
sessionIndex := 0
|
sessionIndex := 0
|
||||||
|
|
||||||
for _, state := range stateChanges {
|
for _, state := range stateChanges {
|
||||||
@@ -433,7 +433,7 @@ func TestStateHistoryService_SessionChangeTracking(t *testing.T) {
|
|||||||
Name: "Test Server",
|
Name: "Test Server",
|
||||||
}
|
}
|
||||||
|
|
||||||
var sessionChanges []string
|
var sessionChanges []model.TrackSession
|
||||||
onStateChange := func(state *model.ServerState, changes ...tracking.StateChange) {
|
onStateChange := func(state *model.ServerState, changes ...tracking.StateChange) {
|
||||||
for _, change := range changes {
|
for _, change := range changes {
|
||||||
if change == tracking.Session {
|
if change == tracking.Session {
|
||||||
@@ -448,7 +448,7 @@ func TestStateHistoryService_SessionChangeTracking(t *testing.T) {
|
|||||||
|
|
||||||
// We'll add one session change at a time and wait briefly to ensure they're processed in order
|
// We'll add one session change at a time and wait briefly to ensure they're processed in order
|
||||||
for _, expected := range testdata.ExpectedSessionChanges {
|
for _, expected := range testdata.ExpectedSessionChanges {
|
||||||
line := "[2024-01-15 14:30:25.123] Session changed: " + expected.From + " -> " + expected.To
|
line := string("[2024-01-15 14:30:25.123] Session changed: " + expected.From + " -> " + expected.To)
|
||||||
instance.HandleLogLine(line)
|
instance.HandleLogLine(line)
|
||||||
// Small pause to ensure log processing completes
|
// Small pause to ensure log processing completes
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|||||||
Reference in New Issue
Block a user