diff --git a/frontend/app.js b/frontend/app.js index d1a71b9..b9424b3 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -30,7 +30,9 @@ let currentMyPage = 1; let currentPublicPage = 1; const KML_PAGE_LIMIT = 10; +// Custom Route Logic let apiKey = null; +let currentUserID = null; // Store current user ID for ownership checks window.isGeneratingRoute = false; @@ -98,6 +100,9 @@ async function checkAuth() { function updateUserProfile(user) { if (!user) return; + currentUserID = user.fitbit_user_id || user.id; // Store ID + console.log(`[KML] User Profile Updated. currentUserID: ${currentUserID}`); + const nameEl = document.getElementById('userName'); const avatarEl = document.getElementById('userAvatar'); const defaultAvatar = 'https://www.fitbit.com/images/profile/defaultProfile_150_male.png'; @@ -115,6 +120,79 @@ function setupLoginHandler() { }); } +// ... + +// (Skipping to openKMLDetails implementation) + +async function openKMLDetails(file) { + currentKmlFile = file; + const overlay = document.getElementById('kml-details-overlay'); + + // Reset UI + document.getElementById('kmlDetailsTitle').textContent = file.filename; + document.getElementById('kmlDetailsDistance').textContent = `📏 ${file.distance.toFixed(2)} km`; + document.getElementById('kmlDetailsAuthor').textContent = file.display_name ? `👤 ${file.display_name}` : ''; + document.getElementById('kmlDetailsVotes').textContent = `👍 ${file.votes}`; + document.getElementById('kmlDescriptionDisplay').textContent = file.description || 'No description provided.'; + + // Show/Hide Edit Button (only for owner) + console.log(`[KML] Opening details. File Owner: ${file.user_id} (type: ${typeof file.user_id}), Current User: ${currentUserID} (type: ${typeof currentUserID})`); + + if (file.user_id === currentUserID) { + document.getElementById('editDescriptionBtn').classList.remove('hidden'); + } else { + document.getElementById('editDescriptionBtn').classList.add('hidden'); + } + + overlay.classList.add('active'); + + // Fetch and Parse KML + try { + const response = await fetch(`/api/kml/download?owner_id=${file.user_id}&filename=${encodeURIComponent(file.filename)}`); + if (!response.ok) throw new Error('Failed to download KML'); + + const kmlText = await response.text(); + const parsed = parseKMLData(kmlText); + + currentKmlPath = parsed.path; + currentKmlMarkers = parsed.markers; + + // Render Map + if (!previewMap) { + previewMap = new google.maps.Map(document.getElementById('kmlPreviewMap'), { + mapTypeId: google.maps.MapTypeId.ROADMAP, + fullscreenControl: false, + streetViewControl: false + }); + } + + // Draw Route + if (previewPolyline) previewPolyline.setMap(null); + previewPolyline = new google.maps.Polyline({ + path: currentKmlPath, + geodesic: true, + strokeColor: '#FF0000', + strokeOpacity: 0.8, + strokeWeight: 4, + map: previewMap + }); + + const bounds = new google.maps.LatLngBounds(); + currentKmlPath.forEach(pt => bounds.extend(pt)); + previewMap.fitBounds(bounds); + + } catch (err) { + console.error('Error loading KML details:', err); + alert('Failed to load route details.'); + } +} + +function setupLoginHandler() { + document.getElementById('fitbitLoginButton').addEventListener('click', () => { + window.location.href = '/auth/fitbit'; + }); +} + async function setupUserMenu() { // This function could fetch user info and display it // For now, the backend handles this via the Fitbit callback @@ -138,6 +216,7 @@ async function setupUserMenu() { function setupKMLBrowser() { // Setup user menu setupUserMenu(); + setupKMLDetailsListeners(); // Tab switching document.querySelectorAll('.kml-tab').forEach(tab => { @@ -291,12 +370,12 @@ function renderKMLFiles(listId, files, isOwnFiles) { const uniqueId = `${listId}-${index}`; try { - // Use This Route button - const useBtn = document.getElementById(`use-${uniqueId}`); - if (useBtn) { - useBtn.addEventListener('click', (e) => { - console.log(`[KML] 'Use' click for: ${file.filename}`); - handleUseRoute(file); + // Open Details button + const openBtn = document.getElementById(`open-${uniqueId}`); + if (openBtn) { + openBtn.addEventListener('click', () => { + console.log(`[KML] 'Open' click for: ${file.filename}`); + openKMLDetails(file); }); } @@ -352,7 +431,7 @@ function createKMLFileHTML(file, isOwnFiles, listId, index) {
- + ${!isOwnFiles ? `
+ +
+
+
+

Route Details

+ +
+ +
+
+ + +
+ + +
+
+
diff --git a/frontend/style.css b/frontend/style.css index 4660b80..9ee867d 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -793,6 +793,7 @@ body { border-color: #ef4444; color: #ef4444; } + /* Secondary Links (Privacy, etc) */ .privacy-link-container { margin-top: 1.5rem; @@ -811,3 +812,168 @@ body { .secondary-link:hover { color: var(--primary); } + +/* KML Details Overlay Styles */ +.kml-details-card { + background: var(--bg-card); + border-radius: 16px; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); + max-width: 900px; + width: 95%; + max-height: 90vh; + display: flex; + flex-direction: column; + border: 1px solid var(--border); +} + +.kml-details-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem 2rem; + border-bottom: 2px solid var(--border); +} + +.kml-details-header h2 { + font-size: 1.5rem; + background: linear-gradient(135deg, var(--primary), var(--secondary)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin: 0; +} + +.kml-details-content { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: row; + /* Side by side on desktop */ +} + +.kml-preview-map { + flex: 1; + min-height: 400px; + background: #000; + border-right: 2px solid var(--border); +} + +.kml-metadata-section { + flex: 1; + padding: 2rem; + display: flex; + flex-direction: column; + gap: 1.5rem; + overflow-y: auto; + min-width: 300px; +} + +.metadata-row { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +.meta-tag { + background: rgba(15, 23, 42, 0.6); + padding: 0.5rem 1rem; + border-radius: 8px; + font-size: 0.9rem; + border: 1px solid var(--border); + color: var(--text-primary); +} + +.description-container { + flex: 1; + display: flex; + flex-direction: column; + background: rgba(15, 23, 42, 0.4); + border-radius: 12px; + border: 1px solid var(--border); + padding: 1rem; +} + +.description-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + border-bottom: 1px solid var(--border); + padding-bottom: 0.5rem; +} + +.description-header h3 { + font-size: 1.1rem; + margin: 0; +} + +.description-text { + flex: 1; + white-space: pre-wrap; + line-height: 1.6; + color: var(--text-secondary); + font-size: 0.95rem; + overflow-y: auto; + max-height: 300px; +} + +.description-editor { + flex: 1; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.description-editor textarea { + width: 100%; + height: 200px; + background: var(--bg-dark); + border: 1px solid var(--border); + padding: 1rem; + color: var(--text-primary); + border-radius: 8px; + resize: vertical; + font-family: inherit; +} + +.editor-actions { + display: flex; + gap: 1rem; + justify-content: flex-end; +} + +.kml-details-footer { + padding: 1.5rem 2rem; + border-top: 2px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; + background: var(--bg-card); +} + +.primary-btn.small { + padding: 0.5rem 1rem; + font-size: 0.9rem; +} + +.primary-btn.large { + font-size: 1.2rem; + padding: 1rem 3rem; + width: auto; +} + +.hidden { + display: none !important; +} + +@media (max-width: 900px) { + .kml-details-content { + flex-direction: column; + } + + .kml-preview-map { + min-height: 300px; + border-right: none; + border-bottom: 2px solid var(--border); + } +} \ No newline at end of file diff --git a/go.mod b/go.mod index ad7ebda..03093aa 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,14 @@ module code.stevenpolley.net/steven/pedestrian-simulator go 1.25.5 -require github.com/go-sql-driver/mysql v1.9.3 +require ( + github.com/go-sql-driver/mysql v1.9.3 + github.com/microcosm-cc/bluemonday v1.0.27 +) -require filippo.io/edwards25519 v1.1.0 // indirect +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/gorilla/css v1.0.1 // indirect + golang.org/x/net v0.26.0 // indirect +) diff --git a/go.sum b/go.sum index 4bcdcfa..3e8d4ba 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,12 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= diff --git a/server/db.go b/server/db.go index b71ab25..6c722ba 100644 --- a/server/db.go +++ b/server/db.go @@ -75,6 +75,7 @@ func createTables() { filename VARCHAR(255), user_id VARCHAR(255), distance DOUBLE, + description TEXT, is_public BOOLEAN DEFAULT FALSE, uploaded_at DATETIME, UNIQUE KEY (user_id, filename), @@ -117,6 +118,8 @@ func createTables() { // Migrations: Ensure trips table uses TIMESTAMP for sync times (breaking change for DATETIME -> TIMESTAMP) db.Exec("ALTER TABLE trips MODIFY last_sync_time TIMESTAMP NULL") db.Exec("ALTER TABLE trips MODIFY next_sync_time TIMESTAMP NULL") + // Migration for KML description + db.Exec("ALTER TABLE kml_metadata ADD COLUMN IF NOT EXISTS description TEXT") log.Println("Database tables initialized") } diff --git a/server/kml.go b/server/kml.go index 5aa3e63..22e4da1 100644 --- a/server/kml.go +++ b/server/kml.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "database/sql" "encoding/json" "encoding/xml" "fmt" @@ -13,6 +14,8 @@ import ( "strconv" "strings" "time" + + "github.com/microcosm-cc/bluemonday" ) // KML structure for parsing @@ -22,11 +25,15 @@ type KML struct { } type KMLDocument struct { - Placemarks []KMLPlacemark `xml:"Placemark"` + Name string `xml:"name"` + Description string `xml:"description"` + Placemarks []KMLPlacemark `xml:"Placemark"` } type KMLPlacemark struct { - LineString KMLLineString `xml:"LineString"` + Name string `xml:"name"` + Description string `xml:"description"` + LineString KMLLineString `xml:"LineString"` } type KMLLineString struct { @@ -39,6 +46,7 @@ type KMLMetadata struct { UserID string `json:"user_id"` DisplayName string `json:"display_name"` // User's display name Distance float64 `json:"distance"` // in kilometers + Description string `json:"description"` // Description from KML or user IsPublic bool `json:"is_public"` UploadedAt time.Time `json:"uploaded_at"` Votes int `json:"votes"` // Net votes (calculated from voting system) @@ -84,8 +92,26 @@ func haversineDistance(lat1, lon1, lat2, lon2 float64) float64 { return earthRadiusKm * c } -// ParseKMLDistance parses a KML file and calculates the total distance -func ParseKMLDistance(kmlData []byte) (float64, error) { +// ParseKML parses a KML file and calculates the total distance and extracts description +func ParseKML(kmlData []byte) (float64, string, error) { + // 1. Extract Description (Best Effort via Struct) + var kml KML + // Ignore errors here as we prioritize distance, description is optional/nice-to-have + _ = xml.Unmarshal(kmlData, &kml) + + description := kml.Document.Description + + // Sanitize description + p := bluemonday.UGCPolicy() + description = p.Sanitize(description) + + if len(description) > 2000 { + description = description[:1997] + "..." + } + + // 2. Calculate Distance (Robust Streaming) + // We re-scan the byte slice using a streaming decoder to find ALL tags, + // regardless of nesting (Document -> Folder -> Folder -> Placemark -> LineString). decoder := xml.NewDecoder(bytes.NewReader(kmlData)) totalDistance := 0.0 @@ -95,21 +121,21 @@ func ParseKMLDistance(kmlData []byte) (float64, error) { break } if err != nil { - return 0, err + // If streaming fails, just return what we have so far? + // Or return error if it's a fundamental XML error. + return totalDistance, description, err } switch se := token.(type) { case xml.StartElement: - // We only care about coordinates within a LineString context. - // However, since Points have only one coordinate and result in 0 anyway, - // searching for any tag is both easier and robust enough. if se.Name.Local == "coordinates" { var coords string if err := decoder.DecodeElement(&coords, &se); err != nil { continue } - // Sum up distance from this LineString + // Calculate distance for this segment + currentSegmentDistance := 0.0 items := strings.Fields(coords) var prevLat, prevLon float64 first := true @@ -128,18 +154,19 @@ func ParseKMLDistance(kmlData []byte) (float64, error) { } if !first { - totalDistance += haversineDistance(prevLat, prevLon, lat, lon) + currentSegmentDistance += haversineDistance(prevLat, prevLon, lat, lon) } prevLat = lat prevLon = lon first = false } + totalDistance += currentSegmentDistance } } } - return totalDistance, nil + return totalDistance, description, nil } // HandleKMLUpload handles KML file uploads @@ -168,6 +195,9 @@ func HandleKMLUpload(w http.ResponseWriter, r *http.Request) { } defer file.Close() + // Sanitize filename to prevent path traversal + handler.Filename = filepath.Base(handler.Filename) + // Read file data data, err := io.ReadAll(file) if err != nil { @@ -175,8 +205,16 @@ func HandleKMLUpload(w http.ResponseWriter, r *http.Request) { return } - // Calculate distance - distance, err := ParseKMLDistance(data) + // Validate MIME type / Content + mimeType := http.DetectContentType(data) + // KML is XML, so valid types are text/xml, application/xml, or text/plain (often detected for simple XML) + if !strings.Contains(mimeType, "xml") && !strings.Contains(mimeType, "text") { + http.Error(w, fmt.Sprintf("Invalid file type: %s. Expected KML/XML.", mimeType), http.StatusBadRequest) + return + } + + // Calculate distance and extract description + distance, description, err := ParseKML(data) if err != nil { http.Error(w, fmt.Sprintf("Failed to parse KML: %v", err), http.StatusBadRequest) return @@ -197,10 +235,10 @@ func HandleKMLUpload(w http.ResponseWriter, r *http.Request) { // Save metadata to database _, err = db.Exec(` - INSERT INTO kml_metadata (filename, user_id, distance, is_public, uploaded_at) - VALUES (?, ?, ?, ?, NOW()) - ON DUPLICATE KEY UPDATE distance = ?, uploaded_at = NOW() - `, handler.Filename, userID, distance, false, distance) + INSERT INTO kml_metadata (filename, user_id, distance, description, is_public, uploaded_at) + VALUES (?, ?, ?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE distance = ?, description = ?, uploaded_at = NOW() + `, handler.Filename, userID, distance, description, false, distance, description) if err != nil { http.Error(w, fmt.Sprintf("Failed to save metadata: %v", err), http.StatusInternalServerError) return @@ -310,7 +348,7 @@ func fetchKMLList(userID string, mineOnly bool, page, limit int, sortBy string) } query := fmt.Sprintf(` - SELECT m.filename, m.user_id, u.display_name, m.distance, m.is_public, m.uploaded_at, + SELECT m.filename, m.user_id, u.display_name, m.distance, m.description, m.is_public, m.uploaded_at, (SELECT COALESCE(SUM(v.vote_value), 0) FROM kml_votes v WHERE v.kml_id = m.id) as votes FROM kml_metadata m JOIN users u ON m.user_id = u.fitbit_user_id @@ -334,15 +372,71 @@ func queryKMLMetadata(query string, args ...interface{}) ([]KMLMetadata, error) var files []KMLMetadata for rows.Next() { var m KMLMetadata - err := rows.Scan(&m.Filename, &m.UserID, &m.DisplayName, &m.Distance, &m.IsPublic, &m.UploadedAt, &m.Votes) + var desc sql.NullString // Handle null description + err := rows.Scan(&m.Filename, &m.UserID, &m.DisplayName, &m.Distance, &desc, &m.IsPublic, &m.UploadedAt, &m.Votes) if err != nil { return nil, err } + m.Description = desc.String files = append(files, m) } return files, nil } +// HandleKMLEdit handles editing KML metadata (description) +func HandleKMLEdit(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 { + Filename string `json:"filename"` + Description string `json:"description"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + // Sanitize description + p := bluemonday.UGCPolicy() + req.Description = p.Sanitize(req.Description) + + if len(req.Description) > 2000 { + req.Description = req.Description[:1997] + "..." + } + + // Update description in database + res, err := db.Exec("UPDATE kml_metadata SET description = ? WHERE filename = ? AND user_id = ?", req.Description, req.Filename, userID) + if err != nil { + http.Error(w, "Failed to update metadata", http.StatusInternalServerError) + return + } + + rowsAffected, _ := res.RowsAffected() + if rowsAffected == 0 { + // Could mean file doesn't exist or description didn't change, check existence + var exists bool + err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM kml_metadata WHERE filename = ? AND user_id = ?)", req.Filename, userID).Scan(&exists) + if err == nil && !exists { + http.Error(w, "File not found", http.StatusNotFound) + return + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + }) +} + // HandleKMLPrivacyToggle toggles the privacy setting of a KML file func HandleKMLPrivacyToggle(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { diff --git a/server/main.go b/server/main.go index d0facaa..4e85db9 100644 --- a/server/main.go +++ b/server/main.go @@ -66,6 +66,7 @@ func main() { user, err := GetUser(userID) if err == nil && user != nil { status["user"] = map[string]string{ + "id": user.FitbitUserID, "displayName": user.DisplayName, "avatarUrl": user.AvatarURL, } @@ -116,6 +117,7 @@ func main() { // 3. KML Management Endpoints http.HandleFunc("/api/kml/upload", RequireAuth(HandleKMLUpload)) http.HandleFunc("/api/kml/list", RequireAuth(HandleKMLList)) + http.HandleFunc("/api/kml/edit", RequireAuth(HandleKMLEdit)) http.HandleFunc("/api/kml/privacy", RequireAuth(HandleKMLPrivacyToggle)) http.HandleFunc("/api/kml/vote", RequireAuth(HandleKMLVote)) http.HandleFunc("/api/kml/delete", RequireAuth(HandleKMLDelete))