anti cheat: don't trust the client, move trip completions to server
All checks were successful
pedestrian-simulator / build (push) Successful in 1m11s

This commit is contained in:
2026-01-14 17:17:58 -07:00
parent f0172afb1e
commit 16c6c9c074
5 changed files with 121 additions and 83 deletions

View File

@@ -969,9 +969,21 @@ async function calculateAndStartRoute(startAddress, endAddress, isRestoring = fa
};
// Initialize step reference
// Initialize/Reset Server Trip
// Only start a new trip on server if this is a fresh start, not a restore
if (!isRestoring) {
fetch('/api/trip', { method: 'POST' }).catch(console.error);
const tripMetadata = {
trip_type: 'address',
route_name: `${leg.start_address} to ${leg.end_address}`,
start_address: leg.start_address,
end_address: leg.end_address,
total_distance: routeTotalDistance / 1000 // In km
};
fetch('/api/trip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tripMetadata)
}).catch(console.error);
}
// Reset display steps for visual consistency
@@ -2126,7 +2138,17 @@ function startFromCustomRoute(pathData, routeName, isRestoring = false, markers
// Initialize/Reset Server Trip
if (!isRestoring) {
fetch('/api/trip', { method: 'POST' }).catch(console.error);
const tripMetadata = {
trip_type: 'kml',
route_name: routeName,
kml_id: currentKmlFile ? currentKmlFile.id : null,
total_distance: routeTotalDistance / 1000 // In km
};
fetch('/api/trip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tripMetadata)
}).catch(console.error);
}
// Reset display steps
@@ -2274,26 +2296,13 @@ async function triggerCelebration(finalDistance) {
overlay.classList.add('active');
createConfetti();
// Report to backend
if (currentTripDetails) {
try {
await fetch('/api/trip/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: currentTripDetails.type,
route_name: currentTripDetails.route_name,
start_address: currentTripDetails.start_address || '',
end_address: currentTripDetails.end_address || '',
kml_filename: currentTripDetails.kml_filename || '',
kml_owner_id: currentTripDetails.kml_owner_id || '',
distance: currentTripDetails.distance
})
});
} catch (err) {
console.error("Failed to record trip completion:", err);
}
}
// Clean up local state
localStorage.removeItem(LOCATION_STORAGE);
currentTripDetails = null;
masterPath = [];
routeTotalDistance = 0;
// We keep window.hasCelebrated = true to prevent immediate re-trigger
// but starting a new trip will reset it.
}
function createConfetti() {

View File

@@ -134,5 +134,13 @@ func createTables() {
// Migration for KML description
db.Exec("ALTER TABLE kml_metadata ADD COLUMN IF NOT EXISTS description TEXT")
// Migration for trips metadata
db.Exec("ALTER TABLE trips ADD COLUMN IF NOT EXISTS trip_type ENUM('address', 'kml') DEFAULT 'address'")
db.Exec("ALTER TABLE trips ADD COLUMN IF NOT EXISTS route_name TEXT")
db.Exec("ALTER TABLE trips ADD COLUMN IF NOT EXISTS start_address TEXT")
db.Exec("ALTER TABLE trips ADD COLUMN IF NOT EXISTS end_address TEXT")
db.Exec("ALTER TABLE trips ADD COLUMN IF NOT EXISTS kml_id INT")
db.Exec("ALTER TABLE trips ADD COLUMN IF NOT EXISTS total_distance DOUBLE")
log.Println("Database tables initialized")
}

View File

@@ -687,7 +687,10 @@ func HandleUserProfile(w http.ResponseWriter, r *http.Request) {
// Fetch completed trips
rows, err := db.Query(`
SELECT ct.id, ct.trip_type, ct.route_name, ct.start_address, ct.end_address, ct.kml_id, ct.distance, ct.completed_at,
SELECT ct.id, ct.trip_type, ct.route_name,
COALESCE(ct.start_address, '') as start_address,
COALESCE(ct.end_address, '') as end_address,
ct.kml_id, ct.distance, ct.completed_at,
m.filename, m.user_id, u.display_name, m.description,
COALESCE((SELECT SUM(vote) FROM kml_votes WHERE kml_id = m.id), 0) as votes
FROM completed_trips ct
@@ -731,51 +734,3 @@ func HandleUserProfile(w http.ResponseWriter, r *http.Request) {
}
// HandleTripComplete records a completed trip
func HandleTripComplete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
userID, ok := getUserID(r.Context())
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req struct {
Type string `json:"type"`
RouteName string `json:"route_name"`
StartAddress string `json:"start_address"`
EndAddress string `json:"end_address"`
KmlFilename string `json:"kml_filename"`
KmlOwnerID string `json:"kml_owner_id"`
Distance float64 `json:"distance"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
var kmlID interface{} = nil
if req.Type == "kml" {
var id int
err := db.QueryRow("SELECT id FROM kml_metadata WHERE user_id = ? AND filename = ?", req.KmlOwnerID, req.KmlFilename).Scan(&id)
if err == nil {
kmlID = id
}
}
_, err := db.Exec(`
INSERT INTO completed_trips (user_id, trip_type, route_name, start_address, end_address, kml_id, distance)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, userID, req.Type, req.RouteName, req.StartAddress, req.EndAddress, kmlID, req.Distance)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to save completed trip: %v", err), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}

View File

@@ -96,9 +96,16 @@ func main() {
return
}
var metadata TripState
if err := json.NewDecoder(r.Body).Decode(&metadata); err != nil {
// Fallback for legacy calls or if no metadata is sent
// But we expect metadata now
fmt.Printf("[API Trip] Warning: Failed to decode metadata: %v\n", err)
}
userID, _ := getUserID(r.Context())
sm := getStepManager(userID)
sm.StartNewTrip()
sm.StartNewTrip(metadata)
w.WriteHeader(http.StatusOK)
}))
@@ -140,9 +147,6 @@ func main() {
// 7. User Profile Endpoint
http.HandleFunc("/api/user/profile", RequireAuth(HandleUserProfile))
// 8. Trip Completion Endpoint
http.HandleFunc("/api/trip/complete", RequireAuth(HandleTripComplete))
// 9. Start Server
binding := "0.0.0.0:8080"
fmt.Printf("Server starting on http://%s\n", binding)

View File

@@ -7,11 +7,21 @@ import (
"time"
)
const METERS_PER_STEP = 0.762 // Match frontend
type TripState struct {
StartDate string `json:"start_date"` // YYYY-MM-DD
StartTime time.Time `json:"start_time"` // Exact start time
StartDayInitialSteps int `json:"start_day_initial_steps"` // Steps on the tracker when trip started
DailyCache map[string]int `json:"daily_cache"` // Cache of steps for past days
// Trip Metadata
TripType string `json:"trip_type"`
RouteName string `json:"route_name"`
StartAddress string `json:"start_address"`
EndAddress string `json:"end_address"`
KmlID *int `json:"kml_id"`
TotalDistance float64 `json:"total_distance"` // in km
}
type StepManager struct {
@@ -47,13 +57,20 @@ func (sm *StepManager) LoadTripState() error {
func (sm *StepManager) loadTripStateLocked() error {
var startTime time.Time
var kmlID sql.NullInt64
err := db.QueryRow(`
SELECT start_date, start_time, start_day_initial_steps, previous_total_steps, target_total_steps, last_sync_time, next_sync_time
SELECT start_date, start_time, start_day_initial_steps, previous_total_steps, target_total_steps, last_sync_time, next_sync_time,
trip_type, route_name, start_address, end_address, kml_id, total_distance
FROM trips WHERE user_id = ?
`, sm.userID).Scan(
&sm.tripState.StartDate, &startTime, &sm.tripState.StartDayInitialSteps,
&sm.previousTotalSteps, &sm.targetTotalSteps, &sm.lastSyncTime, &sm.nextSyncTime,
&sm.tripState.TripType, &sm.tripState.RouteName, &sm.tripState.StartAddress, &sm.tripState.EndAddress, &kmlID, &sm.tripState.TotalDistance,
)
if err == nil && kmlID.Valid {
id := int(kmlID.Int64)
sm.tripState.KmlID = &id
}
if err != nil {
if err == sql.ErrNoRows {
// Initialize with defaults if no trip exists
@@ -94,8 +111,9 @@ func (sm *StepManager) SaveTripState() {
func (sm *StepManager) saveTripStateLocked() {
_, err := db.Exec(`
INSERT INTO trips (user_id, start_date, start_time, start_day_initial_steps, previous_total_steps, target_total_steps, last_sync_time, next_sync_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO trips (user_id, start_date, start_time, start_day_initial_steps, previous_total_steps, target_total_steps, last_sync_time, next_sync_time,
trip_type, route_name, start_address, end_address, kml_id, total_distance)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
start_date = VALUES(start_date),
start_time = VALUES(start_time),
@@ -103,9 +121,16 @@ func (sm *StepManager) saveTripStateLocked() {
previous_total_steps = VALUES(previous_total_steps),
target_total_steps = VALUES(target_total_steps),
last_sync_time = VALUES(last_sync_time),
next_sync_time = VALUES(next_sync_time)
next_sync_time = VALUES(next_sync_time),
trip_type = VALUES(trip_type),
route_name = VALUES(route_name),
start_address = VALUES(start_address),
end_address = VALUES(end_address),
kml_id = VALUES(kml_id),
total_distance = VALUES(total_distance)
`, sm.userID, sm.tripState.StartDate, sm.tripState.StartTime, sm.tripState.StartDayInitialSteps,
sm.previousTotalSteps, sm.targetTotalSteps, sm.lastSyncTime, sm.nextSyncTime)
sm.previousTotalSteps, sm.targetTotalSteps, sm.lastSyncTime, sm.nextSyncTime,
sm.tripState.TripType, sm.tripState.RouteName, sm.tripState.StartAddress, sm.tripState.EndAddress, sm.tripState.KmlID, sm.tripState.TotalDistance)
if err != nil {
fmt.Printf("Error saving trip state: %v\n", err)
}
@@ -123,7 +148,7 @@ func (sm *StepManager) saveTripStateLocked() {
}
}
func (sm *StepManager) StartNewTrip() {
func (sm *StepManager) StartNewTrip(metadata TripState) {
sm.mu.Lock()
defer sm.mu.Unlock()
now := time.Now()
@@ -137,6 +162,12 @@ func (sm *StepManager) StartNewTrip() {
StartTime: now,
StartDayInitialSteps: initialSteps,
DailyCache: make(map[string]int),
TripType: metadata.TripType,
RouteName: metadata.RouteName,
StartAddress: metadata.StartAddress,
EndAddress: metadata.EndAddress,
KmlID: metadata.KmlID,
TotalDistance: metadata.TotalDistance,
}
// On new trip, previous total is 0
sm.previousTotalSteps = 0
@@ -244,8 +275,6 @@ func (sm *StepManager) performSync(interval time.Duration) {
sm.lastSyncTime = time.Now()
sm.nextSyncTime = time.Now().Add(interval)
// Save final state with explicit NOW() for last_sync_time to ensure DB consistency
// next_sync_time is also calculated in SQL relative to the same NOW()
_, err = db.Exec(`
UPDATE trips SET
last_sync_time = NOW(),
@@ -258,9 +287,42 @@ func (sm *StepManager) performSync(interval time.Duration) {
fmt.Printf("Error saving sync completion: %v\n", err)
}
// Check for Trip Completion
if sm.tripState.TotalDistance > 0 {
currentDistance := float64(sm.targetTotalSteps) * METERS_PER_STEP / 1000.0
if currentDistance >= sm.tripState.TotalDistance {
fmt.Printf("[StepManager] Trip Fulfillment Detected! %.2f / %.2f km\n", currentDistance, sm.tripState.TotalDistance)
sm.recordTripCompletionLocked()
}
}
fmt.Printf("Sync Complete. Total Trip Steps: %d\n", sm.targetTotalSteps)
}
func (sm *StepManager) recordTripCompletionLocked() {
// 1. Insert into completed_trips
_, err := db.Exec(`
INSERT INTO completed_trips (user_id, trip_type, route_name, start_address, end_address, kml_id, distance)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, sm.userID, sm.tripState.TripType, sm.tripState.RouteName, sm.tripState.StartAddress, sm.tripState.EndAddress, sm.tripState.KmlID, sm.tripState.TotalDistance)
if err != nil {
fmt.Printf("[StepManager] Error recording completion: %v\n", err)
return
}
// 2. Delete from active trips
_, err = db.Exec("DELETE FROM trips WHERE user_id = ?", sm.userID)
if err != nil {
fmt.Printf("[StepManager] Error clearing active trip: %v\n", err)
}
// 3. Reset local state
sm.tripState.StartDate = ""
sm.previousTotalSteps = 0
sm.targetTotalSteps = 0
}
// calculateSmoothedTokenAt returns the interpolated step count at a given time
func (sm *StepManager) calculateSmoothedTokenAt(t time.Time) int {
totalDuration := sm.nextSyncTime.Sub(sm.lastSyncTime)