package main import ( "crypto/subtle" "encoding/json" "fmt" "io" "log" "net/http" "os" "path/filepath" "strings" "time" "github.com/joho/godotenv" ) const VERSION = "2.5.0" 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"` } type DeltaSyncRequest struct { LastSyncTime string `json:"lastSyncTime"` Gyms []BackupGym `json:"gyms"` Problems []BackupProblem `json:"problems"` Sessions []BackupClimbSession `json:"sessions"` Attempts []BackupAttempt `json:"attempts"` } type DeltaSyncResponse struct { ServerTime string `json:"serverTime"` RequestFullSync bool `json:"requestFullSync,omitempty"` Gyms []BackupGym `json:"gyms"` Problems []BackupProblem `json:"problems"` Sessions []BackupClimbSession `json:"sessions"` Attempts []BackupAttempt `json:"attempts"` } 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"` IsDeleted bool `json:"isDeleted"` 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"` IsDeleted bool `json:"isDeleted"` 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"` IsDeleted bool `json:"isDeleted"` 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"` IsDeleted bool `json:"isDeleted"` CreatedAt string `json:"createdAt"` UpdatedAt *string `json:"updatedAt,omitempty"` } 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{}, }, 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)) // Basic check to see if we have JSON content if len(data) == 0 { return &ClimbDataBackup{ ExportedAt: time.Now().UTC().Format(time.RFC3339), Version: "2.0", FormatVersion: "2.0", Gyms: []BackupGym{}, Problems: []BackupProblem{}, Sessions: []BackupClimbSession{}, Attempts: []BackupAttempt{}, }, nil } 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 { // Resolve update time for comparison updateTime := attempt.CreatedAt if attempt.UpdatedAt != nil { updateTime = *attempt.UpdatedAt } existingUpdateTime := existingAttempt.CreatedAt if existingAttempt.UpdatedAt != nil { existingUpdateTime = *existingAttempt.UpdatedAt } if updateTime >= existingUpdateTime { 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) cleanupTombstones(backup *ClimbDataBackup) { cutoffTime := time.Now().UTC().Add(-90 * 24 * time.Hour) log.Printf("Cleaning up tombstones older than %s", cutoffTime.Format(time.RFC3339)) // Gyms activeGyms := make([]BackupGym, 0, len(backup.Gyms)) for _, item := range backup.Gyms { if !item.IsDeleted { activeGyms = append(activeGyms, item) continue } updatedAt, err := time.Parse(time.RFC3339, item.UpdatedAt) if err == nil && updatedAt.After(cutoffTime) { activeGyms = append(activeGyms, item) } else { log.Printf("Pruning deleted gym: %s", item.ID) } } backup.Gyms = activeGyms // Problems activeProblems := make([]BackupProblem, 0, len(backup.Problems)) for _, item := range backup.Problems { if !item.IsDeleted { activeProblems = append(activeProblems, item) continue } updatedAt, err := time.Parse(time.RFC3339, item.UpdatedAt) if err == nil && updatedAt.After(cutoffTime) { activeProblems = append(activeProblems, item) } else { log.Printf("Pruning deleted problem: %s", item.ID) } } backup.Problems = activeProblems // Sessions activeSessions := make([]BackupClimbSession, 0, len(backup.Sessions)) for _, item := range backup.Sessions { if !item.IsDeleted { activeSessions = append(activeSessions, item) continue } updatedAt, err := time.Parse(time.RFC3339, item.UpdatedAt) if err == nil && updatedAt.After(cutoffTime) { activeSessions = append(activeSessions, item) } else { log.Printf("Pruning deleted session: %s", item.ID) } } backup.Sessions = activeSessions // Attempts activeAttempts := make([]BackupAttempt, 0, len(backup.Attempts)) for _, item := range backup.Attempts { if !item.IsDeleted { activeAttempts = append(activeAttempts, item) continue } timeStr := item.CreatedAt if item.UpdatedAt != nil { timeStr = *item.UpdatedAt } updatedAt, err := time.Parse(time.RFC3339, timeStr) if err == nil && updatedAt.After(cutoffTime) { activeAttempts = append(activeAttempts, item) } else { log.Printf("Pruning deleted attempt: %s", item.ID) } } backup.Attempts = activeAttempts } 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 } s.cleanupTombstones(&backup) 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", r.RemoteAddr, deltaRequest.LastSyncTime, len(deltaRequest.Gyms), len(deltaRequest.Problems), len(deltaRequest.Sessions), len(deltaRequest.Attempts)) // 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 } clientLastSyncCheck, err := time.Parse(time.RFC3339, deltaRequest.LastSyncTime) isServerEmpty := len(serverBackup.Gyms) == 0 && len(serverBackup.Problems) == 0 && len(serverBackup.Sessions) == 0 && len(serverBackup.Attempts) == 0 if err == nil && !clientLastSyncCheck.IsZero() && isServerEmpty { log.Printf("Server is empty but client has sync history. Requesting full sync.") response := DeltaSyncResponse{ ServerTime: time.Now().UTC().Format(time.RFC3339), RequestFullSync: true, Gyms: []BackupGym{}, Problems: []BackupProblem{}, Sessions: []BackupClimbSession{}, Attempts: []BackupAttempt{}, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) 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) s.cleanupTombstones(serverBackup) // 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 { clientLastSync = time.Time{} log.Printf("Warning: Could not parse lastSyncTime '%s', sending all data", deltaRequest.LastSyncTime) } // 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{}, } // 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 modified after client's last sync for _, attempt := range serverBackup.Attempts { attemptTime := attempt.CreatedAt if attempt.UpdatedAt != nil { attemptTime = *attempt.UpdatedAt } parsedTime, err := time.Parse(time.RFC3339, attemptTime) if err == nil && parsedTime.After(clientLastSync) { response.Attempts = append(response.Attempts, attempt) } } log.Printf("Delta sync response to %s: gyms=%d, problems=%d, sessions=%d, attempts=%d", r.RemoteAddr, len(response.Gyms), len(response.Problems), len(response.Sessions), len(response.Attempts)) 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() { godotenv.Load() authToken := os.Getenv("AUTH_TOKEN") print(authToken) 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)) }