Files
Ascently/sync/main.go
Atridad Lahiji 23de8a6fc6
All checks were successful
Ascently - Sync Deploy / build-and-push (push) Successful in 2m31s
Ascently - Docs Deploy / build-and-push (push) Successful in 3m30s
[All Platforms] 2.1.0 - Sync Optimizations
2025-10-15 18:17:19 -06:00

675 lines
20 KiB
Go

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=<name>\n")
fmt.Printf("Image download: GET /images/download?filename=<name>\n")
log.Fatal(http.ListenAndServe(":"+port, nil))
}