All checks were successful
Ascently - Sync Deploy / build-and-push (push) Successful in 2m15s
705 lines
21 KiB
Go
705 lines
21 KiB
Go
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=<name>\n")
|
|
fmt.Printf("Image download: GET /images/download?filename=<name>\n")
|
|
|
|
log.Fatal(http.ListenAndServe(":"+port, nil))
|
|
}
|