package main import ( "crypto/subtle" "encoding/json" "fmt" "io" "log" "net/http" "os" "path/filepath" "strings" "time" ) 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 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) 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 } // Ensure images directory exists 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", "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) 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("/health", server.handleHealth) http.HandleFunc("/images/upload", server.handleImageUpload) http.HandleFunc("/images/download", server.handleImageDownload) fmt.Printf("OpenClimb sync server starting on port %s\n", 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("Image upload: POST /images/upload?filename=\n") fmt.Printf("Image download: GET /images/download?filename=\n") log.Fatal(http.ListenAndServe(":"+port, nil)) }