package main import ( "crypto/subtle" "encoding/json" "fmt" "io" "log" "net/http" "os" "path/filepath" "strings" "time" ) const VERSION = "2.1.0" func min(a, b int) int { if a < b { return a } return b } type DeletedItem struct { ID string `json:"id"` Type string `json:"type"` DeletedAt string `json:"deletedAt"` } type ClimbDataBackup struct { ExportedAt string `json:"exportedAt"` Version string `json:"version"` FormatVersion string `json:"formatVersion"` Gyms []BackupGym `json:"gyms"` Problems []BackupProblem `json:"problems"` Sessions []BackupClimbSession `json:"sessions"` Attempts []BackupAttempt `json:"attempts"` DeletedItems []DeletedItem `json:"deletedItems"` } type DeltaSyncRequest struct { LastSyncTime string `json:"lastSyncTime"` Gyms []BackupGym `json:"gyms"` Problems []BackupProblem `json:"problems"` Sessions []BackupClimbSession `json:"sessions"` Attempts []BackupAttempt `json:"attempts"` DeletedItems []DeletedItem `json:"deletedItems"` } type DeltaSyncResponse struct { ServerTime string `json:"serverTime"` Gyms []BackupGym `json:"gyms"` Problems []BackupProblem `json:"problems"` Sessions []BackupClimbSession `json:"sessions"` Attempts []BackupAttempt `json:"attempts"` DeletedItems []DeletedItem `json:"deletedItems"` } type BackupGym struct { ID string `json:"id"` Name string `json:"name"` Location *string `json:"location,omitempty"` SupportedClimbTypes []string `json:"supportedClimbTypes"` DifficultySystems []string `json:"difficultySystems"` CustomDifficultyGrades []string `json:"customDifficultyGrades"` Notes *string `json:"notes,omitempty"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } type BackupProblem struct { ID string `json:"id"` GymID string `json:"gymId"` Name *string `json:"name,omitempty"` Description *string `json:"description,omitempty"` ClimbType string `json:"climbType"` Difficulty DifficultyGrade `json:"difficulty"` Tags []string `json:"tags"` Location *string `json:"location,omitempty"` ImagePaths []string `json:"imagePaths,omitempty"` IsActive bool `json:"isActive"` DateSet *string `json:"dateSet,omitempty"` Notes *string `json:"notes,omitempty"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } type DifficultyGrade struct { System string `json:"system"` Grade string `json:"grade"` NumericValue int `json:"numericValue"` } type BackupClimbSession struct { ID string `json:"id"` GymID string `json:"gymId"` Date string `json:"date"` StartTime *string `json:"startTime,omitempty"` EndTime *string `json:"endTime,omitempty"` Duration *int64 `json:"duration,omitempty"` Status string `json:"status"` Notes *string `json:"notes,omitempty"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } type BackupAttempt struct { ID string `json:"id"` SessionID string `json:"sessionId"` ProblemID string `json:"problemId"` Result string `json:"result"` HighestHold *string `json:"highestHold,omitempty"` Notes *string `json:"notes,omitempty"` Duration *int64 `json:"duration,omitempty"` RestTime *int64 `json:"restTime,omitempty"` Timestamp string `json:"timestamp"` CreatedAt string `json:"createdAt"` } type SyncServer struct { authToken string dataFile string imagesDir string } func (s *SyncServer) authenticate(r *http.Request) bool { authHeader := r.Header.Get("Authorization") if !strings.HasPrefix(authHeader, "Bearer ") { return false } token := strings.TrimPrefix(authHeader, "Bearer ") return subtle.ConstantTimeCompare([]byte(token), []byte(s.authToken)) == 1 } func (s *SyncServer) loadData() (*ClimbDataBackup, error) { log.Printf("Loading data from: %s", s.dataFile) if _, err := os.Stat(s.dataFile); os.IsNotExist(err) { log.Printf("Data file does not exist, creating empty backup") return &ClimbDataBackup{ ExportedAt: time.Now().UTC().Format(time.RFC3339), Version: "2.0", FormatVersion: "2.0", Gyms: []BackupGym{}, Problems: []BackupProblem{}, Sessions: []BackupClimbSession{}, Attempts: []BackupAttempt{}, DeletedItems: []DeletedItem{}, }, nil } data, err := os.ReadFile(s.dataFile) if err != nil { log.Printf("Failed to read data file: %v", err) return nil, err } log.Printf("Read %d bytes from data file", len(data)) log.Printf("File content preview: %s", string(data[:min(200, len(data))])) var backup ClimbDataBackup if err := json.Unmarshal(data, &backup); err != nil { log.Printf("Failed to unmarshal JSON: %v", err) return nil, err } log.Printf("Loaded backup: gyms=%d, problems=%d, sessions=%d, attempts=%d", len(backup.Gyms), len(backup.Problems), len(backup.Sessions), len(backup.Attempts)) return &backup, nil } func (s *SyncServer) mergeGyms(existing []BackupGym, updates []BackupGym) []BackupGym { gymMap := make(map[string]BackupGym) for _, gym := range existing { gymMap[gym.ID] = gym } for _, gym := range updates { if existingGym, exists := gymMap[gym.ID]; exists { // Keep newer version based on updatedAt timestamp if gym.UpdatedAt >= existingGym.UpdatedAt { gymMap[gym.ID] = gym } } else { gymMap[gym.ID] = gym } } result := make([]BackupGym, 0, len(gymMap)) for _, gym := range gymMap { result = append(result, gym) } return result } func (s *SyncServer) mergeProblems(existing []BackupProblem, updates []BackupProblem) []BackupProblem { problemMap := make(map[string]BackupProblem) for _, problem := range existing { problemMap[problem.ID] = problem } for _, problem := range updates { if existingProblem, exists := problemMap[problem.ID]; exists { if problem.UpdatedAt >= existingProblem.UpdatedAt { problemMap[problem.ID] = problem } } else { problemMap[problem.ID] = problem } } result := make([]BackupProblem, 0, len(problemMap)) for _, problem := range problemMap { result = append(result, problem) } return result } func (s *SyncServer) mergeSessions(existing []BackupClimbSession, updates []BackupClimbSession) []BackupClimbSession { sessionMap := make(map[string]BackupClimbSession) for _, session := range existing { sessionMap[session.ID] = session } for _, session := range updates { if existingSession, exists := sessionMap[session.ID]; exists { if session.UpdatedAt >= existingSession.UpdatedAt { sessionMap[session.ID] = session } } else { sessionMap[session.ID] = session } } result := make([]BackupClimbSession, 0, len(sessionMap)) for _, session := range sessionMap { result = append(result, session) } return result } func (s *SyncServer) mergeAttempts(existing []BackupAttempt, updates []BackupAttempt) []BackupAttempt { attemptMap := make(map[string]BackupAttempt) for _, attempt := range existing { attemptMap[attempt.ID] = attempt } for _, attempt := range updates { if existingAttempt, exists := attemptMap[attempt.ID]; exists { if attempt.CreatedAt >= existingAttempt.CreatedAt { attemptMap[attempt.ID] = attempt } } else { attemptMap[attempt.ID] = attempt } } result := make([]BackupAttempt, 0, len(attemptMap)) for _, attempt := range attemptMap { result = append(result, attempt) } return result } func (s *SyncServer) mergeDeletedItems(existing []DeletedItem, updates []DeletedItem) []DeletedItem { deletedMap := make(map[string]DeletedItem) for _, item := range existing { key := item.Type + ":" + item.ID deletedMap[key] = item } for _, item := range updates { key := item.Type + ":" + item.ID if existingItem, exists := deletedMap[key]; exists { if item.DeletedAt >= existingItem.DeletedAt { deletedMap[key] = item } } else { deletedMap[key] = item } } result := make([]DeletedItem, 0, len(deletedMap)) for _, item := range deletedMap { result = append(result, item) } return result } func (s *SyncServer) applyDeletions(backup *ClimbDataBackup, deletedItems []DeletedItem) { deletedMap := make(map[string]map[string]bool) for _, item := range deletedItems { if deletedMap[item.Type] == nil { deletedMap[item.Type] = make(map[string]bool) } deletedMap[item.Type][item.ID] = true } if deletedMap["gym"] != nil { filtered := []BackupGym{} for _, gym := range backup.Gyms { if !deletedMap["gym"][gym.ID] { filtered = append(filtered, gym) } } backup.Gyms = filtered } if deletedMap["problem"] != nil { filtered := []BackupProblem{} for _, problem := range backup.Problems { if !deletedMap["problem"][problem.ID] { filtered = append(filtered, problem) } } backup.Problems = filtered } if deletedMap["session"] != nil { filtered := []BackupClimbSession{} for _, session := range backup.Sessions { if !deletedMap["session"][session.ID] { filtered = append(filtered, session) } } backup.Sessions = filtered } if deletedMap["attempt"] != nil { filtered := []BackupAttempt{} for _, attempt := range backup.Attempts { if !deletedMap["attempt"][attempt.ID] { filtered = append(filtered, attempt) } } backup.Attempts = filtered } } func (s *SyncServer) saveData(backup *ClimbDataBackup) error { backup.ExportedAt = time.Now().UTC().Format(time.RFC3339) data, err := json.MarshalIndent(backup, "", " ") if err != nil { return err } dir := filepath.Dir(s.dataFile) if err := os.MkdirAll(dir, 0755); err != nil { return err } if err := os.MkdirAll(s.imagesDir, 0755); err != nil { return err } return os.WriteFile(s.dataFile, data, 0644) } func (s *SyncServer) handleGet(w http.ResponseWriter, r *http.Request) { if !s.authenticate(r) { log.Printf("Unauthorized access attempt from %s", r.RemoteAddr) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } log.Printf("GET /sync request from %s", r.RemoteAddr) backup, err := s.loadData() if err != nil { log.Printf("Failed to load data: %v", err) http.Error(w, "Failed to load data", http.StatusInternalServerError) return } log.Printf("Sending data to %s: gyms=%d, problems=%d, sessions=%d, attempts=%d", r.RemoteAddr, len(backup.Gyms), len(backup.Problems), len(backup.Sessions), len(backup.Attempts)) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(backup) } func (s *SyncServer) handlePut(w http.ResponseWriter, r *http.Request) { if !s.authenticate(r) { log.Printf("Unauthorized sync attempt from %s", r.RemoteAddr) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } var backup ClimbDataBackup if err := json.NewDecoder(r.Body).Decode(&backup); err != nil { log.Printf("Invalid JSON from %s: %v", r.RemoteAddr, err) http.Error(w, "Invalid JSON", http.StatusBadRequest) return } if err := s.saveData(&backup); err != nil { log.Printf("Failed to save data: %v", err) http.Error(w, "Failed to save data", http.StatusInternalServerError) return } log.Printf("Data synced by %s", r.RemoteAddr) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(backup) } func (s *SyncServer) handleHealth(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]string{ "status": "healthy", "version": VERSION, "time": time.Now().UTC().Format(time.RFC3339), }) } func (s *SyncServer) handleImageUpload(w http.ResponseWriter, r *http.Request) { if !s.authenticate(r) { log.Printf("Unauthorized image upload attempt from %s", r.RemoteAddr) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } filename := r.URL.Query().Get("filename") if filename == "" { http.Error(w, "Missing filename parameter", http.StatusBadRequest) return } imageData, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Failed to read image data", http.StatusBadRequest) return } imagePath := filepath.Join(s.imagesDir, filename) if err := os.WriteFile(imagePath, imageData, 0644); err != nil { log.Printf("Failed to save image %s: %v", filename, err) http.Error(w, "Failed to save image", http.StatusInternalServerError) return } log.Printf("Image uploaded: %s (%d bytes) by %s", filename, len(imageData), r.RemoteAddr) w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]string{"status": "uploaded"}) } func (s *SyncServer) handleImageDownload(w http.ResponseWriter, r *http.Request) { if !s.authenticate(r) { log.Printf("Unauthorized image download attempt from %s", r.RemoteAddr) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } filename := r.URL.Query().Get("filename") if filename == "" { http.Error(w, "Missing filename parameter", http.StatusBadRequest) return } imagePath := filepath.Join(s.imagesDir, filename) imageData, err := os.ReadFile(imagePath) if err != nil { if os.IsNotExist(err) { http.Error(w, "Image not found", http.StatusNotFound) } else { http.Error(w, "Failed to read image", http.StatusInternalServerError) } return } // Set appropriate content type based on file extension ext := filepath.Ext(filename) switch ext { case ".jpg", ".jpeg": w.Header().Set("Content-Type", "image/jpeg") case ".png": w.Header().Set("Content-Type", "image/png") case ".gif": w.Header().Set("Content-Type", "image/gif") case ".webp": w.Header().Set("Content-Type", "image/webp") default: w.Header().Set("Content-Type", "application/octet-stream") } w.WriteHeader(http.StatusOK) w.Write(imageData) } func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) { if !s.authenticate(r) { log.Printf("Unauthorized delta sync attempt from %s", r.RemoteAddr) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var deltaRequest DeltaSyncRequest if err := json.NewDecoder(r.Body).Decode(&deltaRequest); err != nil { log.Printf("Invalid JSON from %s: %v", r.RemoteAddr, err) http.Error(w, "Invalid JSON", http.StatusBadRequest) return } log.Printf("Delta sync from %s: lastSyncTime=%s, gyms=%d, problems=%d, sessions=%d, attempts=%d, deletedItems=%d", r.RemoteAddr, deltaRequest.LastSyncTime, len(deltaRequest.Gyms), len(deltaRequest.Problems), len(deltaRequest.Sessions), len(deltaRequest.Attempts), len(deltaRequest.DeletedItems)) // Load current server data serverBackup, err := s.loadData() if err != nil { log.Printf("Failed to load data: %v", err) http.Error(w, "Failed to load data", http.StatusInternalServerError) return } // Merge client changes into server data serverBackup.Gyms = s.mergeGyms(serverBackup.Gyms, deltaRequest.Gyms) serverBackup.Problems = s.mergeProblems(serverBackup.Problems, deltaRequest.Problems) serverBackup.Sessions = s.mergeSessions(serverBackup.Sessions, deltaRequest.Sessions) serverBackup.Attempts = s.mergeAttempts(serverBackup.Attempts, deltaRequest.Attempts) serverBackup.DeletedItems = s.mergeDeletedItems(serverBackup.DeletedItems, deltaRequest.DeletedItems) // Apply deletions to remove deleted items s.applyDeletions(serverBackup, serverBackup.DeletedItems) // Save merged data if err := s.saveData(serverBackup); err != nil { log.Printf("Failed to save data: %v", err) http.Error(w, "Failed to save data", http.StatusInternalServerError) return } // Parse client's last sync time clientLastSync, err := time.Parse(time.RFC3339, deltaRequest.LastSyncTime) if err != nil { // If parsing fails, send everything clientLastSync = time.Time{} } // Prepare response with items modified since client's last sync response := DeltaSyncResponse{ ServerTime: time.Now().UTC().Format(time.RFC3339), Gyms: []BackupGym{}, Problems: []BackupProblem{}, Sessions: []BackupClimbSession{}, Attempts: []BackupAttempt{}, DeletedItems: []DeletedItem{}, } // Filter gyms modified after client's last sync for _, gym := range serverBackup.Gyms { gymTime, err := time.Parse(time.RFC3339, gym.UpdatedAt) if err == nil && gymTime.After(clientLastSync) { response.Gyms = append(response.Gyms, gym) } } // Filter problems modified after client's last sync for _, problem := range serverBackup.Problems { problemTime, err := time.Parse(time.RFC3339, problem.UpdatedAt) if err == nil && problemTime.After(clientLastSync) { response.Problems = append(response.Problems, problem) } } // Filter sessions modified after client's last sync for _, session := range serverBackup.Sessions { sessionTime, err := time.Parse(time.RFC3339, session.UpdatedAt) if err == nil && sessionTime.After(clientLastSync) { response.Sessions = append(response.Sessions, session) } } // Filter attempts created after client's last sync for _, attempt := range serverBackup.Attempts { attemptTime, err := time.Parse(time.RFC3339, attempt.CreatedAt) if err == nil && attemptTime.After(clientLastSync) { response.Attempts = append(response.Attempts, attempt) } } // Filter deletions after client's last sync for _, deletedItem := range serverBackup.DeletedItems { deletedTime, err := time.Parse(time.RFC3339, deletedItem.DeletedAt) if err == nil && deletedTime.After(clientLastSync) { response.DeletedItems = append(response.DeletedItems, deletedItem) } } log.Printf("Delta sync response to %s: gyms=%d, problems=%d, sessions=%d, attempts=%d, deletedItems=%d", r.RemoteAddr, len(response.Gyms), len(response.Problems), len(response.Sessions), len(response.Attempts), len(response.DeletedItems)) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } func (s *SyncServer) handleSync(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: s.handleGet(w, r) case http.MethodPut: s.handlePut(w, r) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } func main() { authToken := os.Getenv("AUTH_TOKEN") if authToken == "" { log.Fatal("AUTH_TOKEN environment variable is required") } dataFile := os.Getenv("DATA_FILE") if dataFile == "" { dataFile = "./data/climb_data.json" } imagesDir := os.Getenv("IMAGES_DIR") if imagesDir == "" { imagesDir = "./data/images" } port := os.Getenv("PORT") if port == "" { port = "8080" } server := &SyncServer{ authToken: authToken, dataFile: dataFile, imagesDir: imagesDir, } http.HandleFunc("/sync", server.handleSync) http.HandleFunc("/sync/delta", server.handleDeltaSync) http.HandleFunc("/health", server.handleHealth) http.HandleFunc("/images/upload", server.handleImageUpload) http.HandleFunc("/images/download", server.handleImageDownload) fmt.Printf("Ascently sync server v%s starting on port %s\n", VERSION, port) fmt.Printf("Data file: %s\n", dataFile) fmt.Printf("Images directory: %s\n", imagesDir) fmt.Printf("Health check available at /health\n") fmt.Printf("Delta sync: POST /sync/delta (incremental sync)\n") fmt.Printf("Full sync: GET /sync (download all), PUT /sync (upload all)\n") fmt.Printf("Image upload: POST /images/upload?filename=\n") fmt.Printf("Image download: GET /images/download?filename=\n") log.Fatal(http.ListenAndServe(":"+port, nil)) }