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) {
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))