1.6.0 for Android and 1.1.0 for iOS - Finalizing Export and Import

formats :)
This commit is contained in:
2025-09-28 02:37:03 -06:00
parent cf2e2f7c57
commit c3f847e1e6
48 changed files with 6944 additions and 1107 deletions

14
sync/.env.example Normal file
View File

@@ -0,0 +1,14 @@
# OpenClimb Sync Server Configuration
# Required: Secret token for authentication
# Generate a secure random token and share it between your apps and server
AUTH_TOKEN=your-secure-secret-token-here
# Optional: Port to run the server on (default: 8080)
PORT=8080
# Optional: Path to store the sync data (default: ./data/climb_data.json)
DATA_FILE=./data/climb_data.json
# Optional: Directory to store images (default: ./data/images)
IMAGES_DIR=./data/images

16
sync/.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
# Binaries
sync-server
openclimb-sync
# Go workspace file
go.work
# Data directory
data/
# Environment files
.env
.env.local
# OS generated files
.DS_Store

14
sync/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o sync-server .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/sync-server .
EXPOSE 8080
CMD ["./sync-server"]

12
sync/docker-compose.yml Normal file
View File

@@ -0,0 +1,12 @@
services:
openclimb-sync:
image: ${IMAGE}
ports:
- "8080:8080"
environment:
- AUTH_TOKEN=${AUTH_TOKEN:-your-secret-token-here}
- DATA_FILE=/data/climb_data.json
- IMAGES_DIR=/data/images
volumes:
- ./data:/data
restart: unless-stopped

3
sync/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module openclimb-sync
go 1.25

358
sync/main.go Normal file
View File

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

1
sync/version.md Normal file
View File

@@ -0,0 +1 @@
1.0.0