Browse Source

feat(backend): add version conflict protection, event deletion sync, media revisioning, continuation type, and chunked upload with storage quota

bob.yuxinyang 1 month ago
parent
commit
9e0d78ca1c

+ 11 - 0
.dockerignore

@@ -0,0 +1,11 @@
+.git
+.gitignore
+.env
+.env.*
+vendor
+server
+*.test
+coverage.txt
+deploy.sh
+Dockerfile*
+docker-compose*.yml

+ 4 - 1
.env.example

@@ -1,13 +1,16 @@
 # PostgreSQL
 DB_USER=celestia
-DB_PASSWORD=your_secure_password_here
+DB_PASSWORD=replace_with_a_strong_database_password
 DB_NAME=celestia_trace
 DB_HOST=postgres
 DB_PORT=5432
 
 # JWT
 JWT_SECRET=your_jwt_secret_here_change_me
+ACCESS_TOKEN_TTL=30m
+REFRESH_TOKEN_TTL=720h
 
 # Server
 SERVER_PORT=8080
 GIN_MODE=release
+STORAGE_PATH=/data/uploads

+ 69 - 16
config/config.go

@@ -1,38 +1,91 @@
 package config
 
 import (
+	"fmt"
 	"os"
+	"strings"
+	"time"
 )
 
 // Config holds the application configuration
 type Config struct {
-	DBHost      string
-	DBPort      string
-	DBUser      string
-	DBPassword  string
-	DBName      string
-	JWTSecret   string
-	ServerPort  string
-	GinMode     string
+	DBHost          string
+	DBPort          string
+	DBUser          string
+	DBPassword      string
+	DBName          string
+	JWTSecret       string
+	ServerPort      string
+	GinMode         string
+	StoragePath     string
+	AccessTokenTTL  time.Duration
+	RefreshTokenTTL time.Duration
 }
 
 // Load reads configuration from environment variables with fallbacks
 func Load() *Config {
 	return &Config{
-		DBHost:      getEnv("DB_HOST", "localhost"),
-		DBPort:      getEnv("DB_PORT", "5432"),
-		DBUser:      getEnv("DB_USER", "celestia"),
-		DBPassword:  getEnv("DB_PASSWORD", "celestia_secret"),
-		DBName:      getEnv("DB_NAME", "celestia_trace"),
-		JWTSecret:   getEnv("JWT_SECRET", "change-me-in-production"),
-		ServerPort:  getEnv("SERVER_PORT", "8080"),
-		GinMode:     getEnv("GIN_MODE", "release"),
+		DBHost:          getEnv("DB_HOST", "localhost"),
+		DBPort:          getEnv("DB_PORT", "5432"),
+		DBUser:          getEnv("DB_USER", "celestia"),
+		DBPassword:      getEnv("DB_PASSWORD", "celestia_secret"),
+		DBName:          getEnv("DB_NAME", "celestia_trace"),
+		JWTSecret:       getEnv("JWT_SECRET", "change-me-in-production"),
+		ServerPort:      getEnv("SERVER_PORT", "8080"),
+		GinMode:         getEnv("GIN_MODE", "release"),
+		StoragePath:     getEnv("STORAGE_PATH", "/data/uploads"),
+		AccessTokenTTL:  getDurationEnv("ACCESS_TOKEN_TTL", 30*time.Minute),
+		RefreshTokenTTL: getDurationEnv("REFRESH_TOKEN_TTL", 30*24*time.Hour),
 	}
 }
 
+// Validate prevents production from silently starting with repository defaults.
+func (c *Config) Validate() error {
+	requiredDatabaseSettings := map[string]string{
+		"DB_HOST":     c.DBHost,
+		"DB_PORT":     c.DBPort,
+		"DB_USER":     c.DBUser,
+		"DB_PASSWORD": c.DBPassword,
+		"DB_NAME":     c.DBName,
+	}
+	for name, value := range requiredDatabaseSettings {
+		if strings.TrimSpace(value) == "" {
+			return fmt.Errorf("%s must not be empty", name)
+		}
+	}
+
+	if c.GinMode == "release" {
+		weakSecrets := []string{"", "change-me-in-production", "your_jwt_secret_here_change_me"}
+		for _, weak := range weakSecrets {
+			if c.JWTSecret == weak {
+				return fmt.Errorf("JWT_SECRET must be configured with a production secret")
+			}
+		}
+		if len(c.JWTSecret) < 32 {
+			return fmt.Errorf("JWT_SECRET must contain at least 32 characters")
+		}
+	}
+	if strings.TrimSpace(c.StoragePath) == "" {
+		return fmt.Errorf("STORAGE_PATH must not be empty")
+	}
+	return nil
+}
+
 func getEnv(key, fallback string) string {
 	if value, exists := os.LookupEnv(key); exists {
 		return value
 	}
 	return fallback
 }
+
+func getDurationEnv(key string, fallback time.Duration) time.Duration {
+	value, exists := os.LookupEnv(key)
+	if !exists {
+		return fallback
+	}
+	parsed, err := time.ParseDuration(value)
+	if err != nil || parsed <= 0 {
+		return fallback
+	}
+	return parsed
+}

+ 10 - 0
config/config_test.go

@@ -0,0 +1,10 @@
+package config
+
+import "testing"
+
+func TestReleaseConfigurationRejectsDefaultSecret(t *testing.T) {
+	cfg := &Config{GinMode: "release", JWTSecret: "change-me-in-production", StoragePath: "/tmp/uploads"}
+	if err := cfg.Validate(); err == nil {
+		t.Fatal("expected default JWT secret to be rejected")
+	}
+}

+ 11 - 2
database/database.go

@@ -22,8 +22,17 @@ func Init(cfg *config.Config) {
 		log.Fatalf("Failed to connect to database: %v", err)
 	}
 
-	// Auto-migrate the User model
-	err = db.AutoMigrate(&models.User{})
+	// Auto-migrate models
+	err = db.AutoMigrate(
+		&models.User{},
+		&models.AuthSession{},
+		&models.BoundDevice{},
+		&models.CelestiaSession{},
+		&models.TimelineEvent{},
+		&models.MediaAsset{},
+		&models.SyncSetting{},
+		&models.ChunkedUpload{},
+	)
 	if err != nil {
 		log.Fatalf("Failed to auto-migrate database: %v", err)
 	}

+ 68 - 30
deploy.sh

@@ -1,47 +1,85 @@
-#!/bin/bash
-# CelestiaTrace Backend Deployment Script
-# Usage: ./deploy.sh
+#!/usr/bin/env bash
+# CelestiaTrace backend deployment.
+# This updates only the backend service; PostgreSQL is not recreated or stopped.
 
-set -e
+set -Eeuo pipefail
 
 SERVER_IP="47.93.193.127"
 PEM_KEY="../ccdw-meishi-1.pem"
-REMOTE_DIR="/opt/celestia-trace"
+REMOTE_DIR="/opt/celestia-trace-backend"
 SSH_USER="root"
+COMPOSE_PROJECT="celestia-trace-backend"
+PROXY_DIR="/opt/meishi_ccdw_life"
+PROXY_CONFIG="../remote_nginx.conf"
+PUBLIC_URL="https://api.ccdw.life/celestia-trace/v1"
+SSH_OPTS=(-i "$PEM_KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new)
 
-echo "🚀 Deploying CelestiaTrace Backend..."
+if [[ ! -f "$PEM_KEY" ]]; then
+  echo "SSH key not found: $PEM_KEY" >&2
+  exit 1
+fi
+if [[ ! -f "$PROXY_CONFIG" ]]; then
+  echo "Nginx config not found: $PROXY_CONFIG" >&2
+  exit 1
+fi
 
-# Ensure PEM key has correct permissions
 chmod 400 "$PEM_KEY"
+ssh "${SSH_OPTS[@]}" "$SSH_USER@$SERVER_IP" "mkdir -p '$REMOTE_DIR'"
 
-# Create remote directory
-ssh -i "$PEM_KEY" -o StrictHostKeyChecking=no "$SSH_USER@$SERVER_IP" "mkdir -p $REMOTE_DIR"
-
-# Sync backend files to server
-rsync -avz --exclude '.git' --exclude 'vendor' \
-  -e "ssh -i $PEM_KEY -o StrictHostKeyChecking=no" \
+rsync -avz --delete \
+  --exclude '.git' \
+  --exclude '.env' \
+  --exclude 'vendor' \
+  -e "ssh -i $PEM_KEY -o BatchMode=yes -o StrictHostKeyChecking=accept-new" \
   ./ "$SSH_USER@$SERVER_IP:$REMOTE_DIR/"
 
-# SSH into server and deploy with Docker Compose
-ssh -i "$PEM_KEY" -o StrictHostKeyChecking=no "$SSH_USER@$SERVER_IP" << 'REMOTE_CMDS'
-cd /opt/celestia-trace
+scp "${SSH_OPTS[@]}" "$PROXY_CONFIG" "$SSH_USER@$SERVER_IP:$PROXY_DIR/nginx.conf.next"
 
-# Copy .env from example if not exists
-if [ ! -f .env ]; then
-  cp .env.example .env
-  echo "⚠️  Created .env from template. Please edit /opt/celestia-trace/.env with secure values!"
+ssh "${SSH_OPTS[@]}" "$SSH_USER@$SERVER_IP" << REMOTE_CMDS
+set -Eeuo pipefail
+cd "$REMOTE_DIR"
+
+if [[ ! -f .env ]]; then
+  echo "Missing $REMOTE_DIR/.env; refusing to deploy with template secrets" >&2
+  exit 1
 fi
+chmod 600 .env
+
+docker compose -p "$COMPOSE_PROJECT" config --quiet
+docker compose -p "$COMPOSE_PROJECT" up -d --build --no-deps backend
+
+for attempt in \$(seq 1 30); do
+  health=\$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' celestia-backend)
+  if [[ "\$health" == "healthy" ]]; then
+    docker compose -p "$COMPOSE_PROJECT" ps backend
+    exit 0
+  fi
+  if [[ "\$health" == "unhealthy" || "\$health" == "exited" ]]; then
+    docker logs --tail 100 celestia-backend
+    exit 1
+  fi
+  sleep 2
+done
+
+echo "Backend health check timed out" >&2
+docker logs --tail 100 celestia-backend
+exit 1
+REMOTE_CMDS
+
+ssh "${SSH_OPTS[@]}" "$SSH_USER@$SERVER_IP" << REMOTE_CMDS
+set -Eeuo pipefail
+cd "$PROXY_DIR"
 
-# Pull latest images and rebuild
-docker compose down
-docker compose up -d --build
+docker run --rm \
+  --network meishi_ccdw_life_default \
+  -v "$PROXY_DIR/nginx.conf.next:/etc/nginx/nginx.conf:ro" \
+  -v /etc/letsencrypt:/etc/letsencrypt:ro \
+  docker.m.daocloud.io/library/nginx:alpine nginx -t
 
-# Show status
-echo ""
-echo "✅ Deployment complete!"
-echo "Services status:"
-docker compose ps
+cp nginx.conf.next nginx.conf
+chmod 644 nginx.conf
+rm -f nginx.conf.next
+docker exec meishi_ccdw_life-proxy-1 nginx -s reload
 REMOTE_CMDS
 
-echo ""
-echo "🎉 Done! Your backend is live at https://celestia-trace.ccdw.life"
+echo "Backend deployed: $PUBLIC_URL"

+ 32 - 5
docker-compose.yml

@@ -5,7 +5,7 @@ services:
     restart: unless-stopped
     environment:
       POSTGRES_USER: ${DB_USER:-celestia}
-      POSTGRES_PASSWORD: ${DB_PASSWORD:-celestia_secret}
+      POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
       POSTGRES_DB: ${DB_NAME:-celestia_trace}
     volumes:
       - pgdata:/var/lib/postgresql/data
@@ -28,13 +28,40 @@ services:
       DB_HOST: postgres
       DB_PORT: 5432
       DB_USER: ${DB_USER:-celestia}
-      DB_PASSWORD: ${DB_PASSWORD:-celestia_secret}
+      DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
       DB_NAME: ${DB_NAME:-celestia_trace}
-      JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
+      JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}
+      ACCESS_TOKEN_TTL: ${ACCESS_TOKEN_TTL:-30m}
+      REFRESH_TOKEN_TTL: ${REFRESH_TOKEN_TTL:-720h}
+      STORAGE_PATH: /data/uploads
       SERVER_PORT: 8080
       GIN_MODE: release
-    ports:
-      - "8080:8080"
+    expose:
+      - "8080"
+    volumes:
+      - uploads:/data/uploads
+    networks:
+      default:
+      proxy:
+        aliases:
+          - celestia-backend
+    healthcheck:
+      test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/v1/health >/dev/null || exit 1"]
+      interval: 10s
+      timeout: 3s
+      retries: 5
+      start_period: 10s
+    logging:
+      driver: json-file
+      options:
+        max-size: "10m"
+        max-file: "3"
 
 volumes:
   pgdata:
+  uploads:
+
+networks:
+  proxy:
+    name: meishi_ccdw_life_default
+    external: true

+ 177 - 50
handlers/auth_handler.go

@@ -1,6 +1,10 @@
 package handlers
 
 import (
+	"crypto/rand"
+	"crypto/sha256"
+	"encoding/base64"
+	"encoding/hex"
 	"errors"
 	"net/http"
 	"strings"
@@ -27,71 +31,132 @@ func respondSuccess(c *gin.Context, data interface{}) {
 	c.JSON(http.StatusOK, gin.H{"code": 0, "message": "success", "data": data})
 }
 
-func (h *AuthHandler) generateToken(userID string) (string, error) {
+func (h *AuthHandler) generateAccessToken(userID, sessionID string) (string, time.Time, error) {
+	now := time.Now().UTC()
+	expiresAt := now.Add(h.Cfg.AccessTokenTTL)
 	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
 		"sub": userID,
-		"exp": time.Now().Add(7 * 24 * time.Hour).Unix(),
+		"sid": sessionID,
+		"iss": "celestia-trace",
+		"iat": now.Unix(),
+		"exp": expiresAt.Unix(),
 	})
-	return token.SignedString([]byte(h.Cfg.JWTSecret))
+	signed, err := token.SignedString([]byte(h.Cfg.JWTSecret))
+	return signed, expiresAt, err
+}
+
+func newRefreshToken() (string, string, error) {
+	raw := make([]byte, 32)
+	if _, err := rand.Read(raw); err != nil {
+		return "", "", err
+	}
+	token := base64.RawURLEncoding.EncodeToString(raw)
+	sum := sha256.Sum256([]byte(token))
+	return token, hex.EncodeToString(sum[:]), nil
+}
+
+func hashRefreshToken(token string) string {
+	sum := sha256.Sum256([]byte(token))
+	return hex.EncodeToString(sum[:])
+}
+
+func (h *AuthHandler) issueTokens(tx *gorm.DB, user models.User) (models.AuthResponse, error) {
+	refreshToken, refreshHash, err := newRefreshToken()
+	if err != nil {
+		return models.AuthResponse{}, err
+	}
+	session := models.AuthSession{
+		UserID:      user.ID,
+		RefreshHash: refreshHash,
+		ExpiresAt:   time.Now().UTC().Add(h.Cfg.RefreshTokenTTL),
+	}
+	if err := tx.Create(&session).Error; err != nil {
+		return models.AuthResponse{}, err
+	}
+	accessToken, expiresAt, err := h.generateAccessToken(user.ID, session.ID)
+	if err != nil {
+		return models.AuthResponse{}, err
+	}
+	return models.AuthResponse{
+		User:         user,
+		Token:        accessToken,
+		RefreshToken: refreshToken,
+		ExpiresAt:    expiresAt,
+	}, nil
+}
+
+func normalizeIdentifier(identifier string) string {
+	identifier = strings.TrimSpace(identifier)
+	if strings.Contains(identifier, "@") {
+		return strings.ToLower(identifier)
+	}
+	return strings.ReplaceAll(identifier, " ", "")
 }
 
 func isEmail(identifier string) bool {
 	return strings.Contains(identifier, "@")
 }
 
-// Register handler
+func isDuplicateError(err error) bool {
+	if err == nil {
+		return false
+	}
+	message := strings.ToLower(err.Error())
+	return strings.Contains(message, "duplicate key") || strings.Contains(message, "unique constraint")
+}
+
+// Register creates the server account and an immediately revocable login session.
 func (h *AuthHandler) Register(c *gin.Context) {
 	var req models.RegisterRequest
 	if err := c.ShouldBindJSON(&req); err != nil {
 		respondError(c, http.StatusBadRequest, 400, "invalid input data")
 		return
 	}
+	req.Username = strings.TrimSpace(req.Username)
+	req.Identifier = normalizeIdentifier(req.Identifier)
 
-	hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 10)
+	hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
 	if err != nil {
 		respondError(c, http.StatusInternalServerError, 500, "failed to hash password")
 		return
 	}
-
-	user := models.User{
-		Username:     req.Username,
-		PasswordHash: string(hash),
-	}
-
+	user := models.User{Username: req.Username, PasswordHash: string(hash)}
 	if isEmail(req.Identifier) {
 		user.Email = &req.Identifier
 	} else {
 		user.PhoneNumber = &req.Identifier
 	}
 
-	if err := h.DB.Create(&user).Error; err != nil {
-		if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "UNIQUE constraint") {
+	var response models.AuthResponse
+	err = h.DB.Transaction(func(tx *gorm.DB) error {
+		if err := tx.Create(&user).Error; err != nil {
+			return err
+		}
+		issued, err := h.issueTokens(tx, user)
+		response = issued
+		return err
+	})
+	if err != nil {
+		if isDuplicateError(err) {
 			respondError(c, http.StatusConflict, 409, "username or identifier already exists")
 			return
 		}
 		respondError(c, http.StatusInternalServerError, 500, "failed to create user")
 		return
 	}
-
-	token, err := h.generateToken(user.ID)
-	if err != nil {
-		respondError(c, http.StatusInternalServerError, 500, "failed to generate token")
-		return
-	}
-
-	respondSuccess(c, models.AuthResponse{User: user, Token: token})
+	respondSuccess(c, response)
 }
 
-// Login handler
+// Login supports username, email, or phone number.
 func (h *AuthHandler) Login(c *gin.Context) {
 	var req models.LoginRequest
 	if err := c.ShouldBindJSON(&req); err != nil {
 		respondError(c, http.StatusBadRequest, 400, "invalid input data")
 		return
 	}
-
+	identifier := normalizeIdentifier(req.Identifier)
 	var user models.User
-	if err := h.DB.Where("username = ? OR email = ? OR phone_number = ?", req.Identifier, req.Identifier, req.Identifier).First(&user).Error; err != nil {
+	if err := h.DB.Where("LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?) OR phone_number = ?", identifier, identifier, identifier).First(&user).Error; err != nil {
 		if errors.Is(err, gorm.ErrRecordNotFound) {
 			respondError(c, http.StatusUnauthorized, 401, "invalid credentials")
 			return
@@ -99,28 +164,69 @@ func (h *AuthHandler) Login(c *gin.Context) {
 		respondError(c, http.StatusInternalServerError, 500, "database error")
 		return
 	}
-
 	if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
 		respondError(c, http.StatusUnauthorized, 401, "invalid credentials")
 		return
 	}
+	response, err := h.issueTokens(h.DB, user)
+	if err != nil {
+		respondError(c, http.StatusInternalServerError, 500, "failed to create login session")
+		return
+	}
+	respondSuccess(c, response)
+}
 
-	token, err := h.generateToken(user.ID)
+// Refresh rotates a refresh token so a stolen token cannot be replayed.
+func (h *AuthHandler) Refresh(c *gin.Context) {
+	var req models.RefreshRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		respondError(c, http.StatusBadRequest, 400, "refresh token is required")
+		return
+	}
+	var oldSession models.AuthSession
+	err := h.DB.Where("refresh_hash = ? AND revoked_at IS NULL AND expires_at > ?", hashRefreshToken(req.RefreshToken), time.Now().UTC()).First(&oldSession).Error
 	if err != nil {
-		respondError(c, http.StatusInternalServerError, 500, "failed to generate token")
+		respondError(c, http.StatusUnauthorized, 401, "invalid or expired refresh token")
+		return
+	}
+	var user models.User
+	if err := h.DB.First(&user, "id = ?", oldSession.UserID).Error; err != nil {
+		respondError(c, http.StatusUnauthorized, 401, "user no longer exists")
 		return
 	}
 
-	respondSuccess(c, models.AuthResponse{User: user, Token: token})
+	var response models.AuthResponse
+	err = h.DB.Transaction(func(tx *gorm.DB) error {
+		now := time.Now().UTC()
+		result := tx.Model(&models.AuthSession{}).
+			Where("id = ? AND revoked_at IS NULL", oldSession.ID).
+			Updates(map[string]interface{}{"revoked_at": &now, "last_used_at": &now})
+		if result.Error != nil {
+			return result.Error
+		}
+		if result.RowsAffected != 1 {
+			return gorm.ErrRecordNotFound
+		}
+		issued, err := h.issueTokens(tx, user)
+		response = issued
+		return err
+	})
+	if err != nil {
+		respondError(c, http.StatusUnauthorized, 401, "refresh token has already been used")
+		return
+	}
+	respondSuccess(c, response)
 }
 
-// Logout handler
 func (h *AuthHandler) Logout(c *gin.Context) {
-	// Client side discards the token
+	sessionID := c.GetString("sessionID")
+	if sessionID != "" {
+		now := time.Now().UTC()
+		h.DB.Model(&models.AuthSession{}).Where("id = ?", sessionID).Update("revoked_at", &now)
+	}
 	respondSuccess(c, nil)
 }
 
-// GetProfile handler
 func (h *AuthHandler) GetProfile(c *gin.Context) {
 	userID := c.GetString("userID")
 	var user models.User
@@ -131,7 +237,6 @@ func (h *AuthHandler) GetProfile(c *gin.Context) {
 	respondSuccess(c, user)
 }
 
-// UpdateProfile handler
 func (h *AuthHandler) UpdateProfile(c *gin.Context) {
 	userID := c.GetString("userID")
 	var req models.UpdateProfileRequest
@@ -139,35 +244,54 @@ func (h *AuthHandler) UpdateProfile(c *gin.Context) {
 		respondError(c, http.StatusBadRequest, 400, "invalid input data")
 		return
 	}
-
 	var user models.User
 	if err := h.DB.First(&user, "id = ?", userID).Error; err != nil {
 		respondError(c, http.StatusNotFound, 404, "user not found")
 		return
 	}
-
-	if req.Username != nil && len(*req.Username) >= 2 {
-		user.Username = *req.Username
+	if req.Username != nil {
+		trimmed := strings.TrimSpace(*req.Username)
+		if len([]rune(trimmed)) < 2 {
+			respondError(c, http.StatusBadRequest, 400, "username must contain at least 2 characters")
+			return
+		}
+		user.Username = trimmed
 	}
 	if req.Email != nil {
-		user.Email = req.Email
+		value := strings.ToLower(strings.TrimSpace(*req.Email))
+		if value == "" {
+			user.Email = nil
+		} else {
+			user.Email = &value
+		}
 	}
 	if req.PhoneNumber != nil {
-		user.PhoneNumber = req.PhoneNumber
+		value := normalizeIdentifier(*req.PhoneNumber)
+		if value == "" {
+			user.PhoneNumber = nil
+		} else {
+			user.PhoneNumber = &value
+		}
 	}
 	if req.AvatarURL != nil {
-		user.AvatarURL = req.AvatarURL
+		value := strings.TrimSpace(*req.AvatarURL)
+		if value == "" {
+			user.AvatarURL = nil
+		} else {
+			user.AvatarURL = &value
+		}
 	}
-
 	if err := h.DB.Save(&user).Error; err != nil {
+		if isDuplicateError(err) {
+			respondError(c, http.StatusConflict, 409, "username, email, or phone already exists")
+			return
+		}
 		respondError(c, http.StatusInternalServerError, 500, "failed to update profile")
 		return
 	}
-
 	respondSuccess(c, user)
 }
 
-// ChangePassword handler
 func (h *AuthHandler) ChangePassword(c *gin.Context) {
 	userID := c.GetString("userID")
 	var req models.ChangePasswordRequest
@@ -175,29 +299,32 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
 		respondError(c, http.StatusBadRequest, 400, "invalid input data")
 		return
 	}
-
 	var user models.User
 	if err := h.DB.First(&user, "id = ?", userID).Error; err != nil {
 		respondError(c, http.StatusNotFound, 404, "user not found")
 		return
 	}
-
 	if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.OldPassword)); err != nil {
 		respondError(c, http.StatusUnauthorized, 401, "invalid old password")
 		return
 	}
-
-	hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), 10)
+	hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
 	if err != nil {
 		respondError(c, http.StatusInternalServerError, 500, "failed to hash new password")
 		return
 	}
-
-	user.PasswordHash = string(hash)
-	if err := h.DB.Save(&user).Error; err != nil {
+	err = h.DB.Transaction(func(tx *gorm.DB) error {
+		if err := tx.Model(&user).Update("password_hash", string(hash)).Error; err != nil {
+			return err
+		}
+		now := time.Now().UTC()
+		return tx.Model(&models.AuthSession{}).
+			Where("user_id = ? AND id <> ? AND revoked_at IS NULL", userID, c.GetString("sessionID")).
+			Update("revoked_at", &now).Error
+	})
+	if err != nil {
 		respondError(c, http.StatusInternalServerError, 500, "failed to change password")
 		return
 	}
-
 	respondSuccess(c, nil)
 }

+ 25 - 0
handlers/auth_handler_test.go

@@ -0,0 +1,25 @@
+package handlers
+
+import "testing"
+
+func TestRefreshTokenHashRoundTrip(t *testing.T) {
+	token, hash, err := newRefreshToken()
+	if err != nil {
+		t.Fatalf("newRefreshToken returned error: %v", err)
+	}
+	if token == "" || len(hash) != 64 {
+		t.Fatalf("unexpected token/hash lengths: %d/%d", len(token), len(hash))
+	}
+	if got := hashRefreshToken(token); got != hash {
+		t.Fatalf("hash mismatch: got %q want %q", got, hash)
+	}
+}
+
+func TestNormalizeIdentifier(t *testing.T) {
+	if got := normalizeIdentifier("  User@Example.COM "); got != "user@example.com" {
+		t.Fatalf("email normalization = %q", got)
+	}
+	if got := normalizeIdentifier("138 0000 0000"); got != "13800000000" {
+		t.Fatalf("phone normalization = %q", got)
+	}
+}

+ 119 - 0
handlers/device_handler.go

@@ -0,0 +1,119 @@
+package handlers
+
+import (
+	"net/http"
+
+	"github.com/celestia-trace/backend/models"
+	"github.com/gin-gonic/gin"
+	"gorm.io/gorm"
+)
+
+type DeviceHandler struct {
+	DB *gorm.DB
+}
+
+// GetDevices returns all bound devices for the authenticated user
+func (h *DeviceHandler) GetDevices(c *gin.Context) {
+	userID := c.GetString("userID")
+	var devices []models.BoundDevice
+	if err := h.DB.Where("user_id = ?", userID).Order("bound_at desc").Find(&devices).Error; err != nil {
+		respondError(c, http.StatusInternalServerError, 500, "failed to query devices")
+		return
+	}
+	respondSuccess(c, devices)
+}
+
+// BindDevice binds a new Spark device to the authenticated user
+func (h *DeviceHandler) BindDevice(c *gin.Context) {
+	userID := c.GetString("userID")
+	var req models.BindDeviceRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		respondError(c, http.StatusBadRequest, 400, "invalid device parameters")
+		return
+	}
+
+	var device models.BoundDevice
+	err := h.DB.Where("user_id = ? AND peripheral_uuid = ?", userID, req.PeripheralUUID).First(&device).Error
+	if err != nil && err != gorm.ErrRecordNotFound {
+		respondError(c, http.StatusInternalServerError, 500, "failed to query device")
+		return
+	}
+	device.UserID = userID
+	device.Name = req.Name
+	device.PeripheralUUID = req.PeripheralUUID
+	device.HardwareMAC = req.HardwareMAC
+	device.FirmwareVersion = req.FirmwareVersion
+	if req.BatteryLevel != nil {
+		device.BatteryLevel = *req.BatteryLevel
+	}
+	if req.FreeStorageMB != nil {
+		device.FreeStorageMB = *req.FreeStorageMB
+	}
+	if req.TotalStorageMB != nil {
+		device.TotalStorageMB = *req.TotalStorageMB
+	}
+	device.IsConnected = true
+	if err := h.DB.Save(&device).Error; err != nil {
+		respondError(c, http.StatusInternalServerError, 500, "failed to bind device")
+		return
+	}
+
+	respondSuccess(c, device)
+}
+
+// UpdateDevice updates device attributes (e.g. name or online status)
+func (h *DeviceHandler) UpdateDevice(c *gin.Context) {
+	userID := c.GetString("userID")
+	deviceID := c.Param("id")
+
+	var req models.UpdateDeviceRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		respondError(c, http.StatusBadRequest, 400, "invalid input data")
+		return
+	}
+
+	var device models.BoundDevice
+	if err := h.DB.Where("id = ? AND user_id = ?", deviceID, userID).First(&device).Error; err != nil {
+		respondError(c, http.StatusNotFound, 404, "device not found")
+		return
+	}
+
+	if req.Name != nil {
+		device.Name = *req.Name
+	}
+	if req.BatteryLevel != nil {
+		device.BatteryLevel = *req.BatteryLevel
+	}
+	if req.IsConnected != nil {
+		device.IsConnected = *req.IsConnected
+	}
+	if req.FirmwareVersion != nil {
+		device.FirmwareVersion = *req.FirmwareVersion
+	}
+	if req.FreeStorageMB != nil {
+		device.FreeStorageMB = *req.FreeStorageMB
+	}
+	if req.TotalStorageMB != nil {
+		device.TotalStorageMB = *req.TotalStorageMB
+	}
+
+	if err := h.DB.Save(&device).Error; err != nil {
+		respondError(c, http.StatusInternalServerError, 500, "failed to update device")
+		return
+	}
+
+	respondSuccess(c, device)
+}
+
+// UnbindDevice unbinds a Spark device
+func (h *DeviceHandler) UnbindDevice(c *gin.Context) {
+	userID := c.GetString("userID")
+	deviceID := c.Param("id")
+
+	if err := h.DB.Where("id = ? AND user_id = ?", deviceID, userID).Delete(&models.BoundDevice{}).Error; err != nil {
+		respondError(c, http.StatusInternalServerError, 500, "failed to unbind device")
+		return
+	}
+
+	respondSuccess(c, gin.H{"deletedId": deviceID})
+}

+ 480 - 0
handlers/media_handler.go

@@ -0,0 +1,480 @@
+package handlers
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"fmt"
+	"io"
+	"math"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/celestia-trace/backend/config"
+	"github.com/celestia-trace/backend/models"
+	"github.com/gin-gonic/gin"
+	"gorm.io/gorm"
+)
+
+const maxAssetUploadBytes int64 = 2 * 1024 * 1024 * 1024 // 2 GiB
+
+type MediaHandler struct {
+	DB  *gorm.DB
+	Cfg *config.Config
+}
+
+// UploadAsset stores a real session attachment. clientId makes retries
+// idempotent; the SHA-256 digest allows clients to verify the stored content.
+func (h *MediaHandler) UploadAsset(c *gin.Context) {
+	userID := c.GetString("userID")
+	sessionID := c.Param("id")
+	var session models.CelestiaSession
+	if err := h.DB.Where("id = ? AND user_id = ? AND deleted_at IS NULL", sessionID, userID).First(&session).Error; err != nil {
+		respondError(c, http.StatusNotFound, 404, "record not found")
+		return
+	}
+
+	c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxAssetUploadBytes)
+	clientID := strings.TrimSpace(c.PostForm("clientId"))
+	kind := strings.ToUpper(strings.TrimSpace(c.PostForm("kind")))
+	if clientID == "" || (kind != "AUDIO" && kind != "PHOTO") {
+		respondError(c, http.StatusBadRequest, 400, "clientId and a valid kind are required")
+		return
+	}
+	var existing models.MediaAsset
+	if err := h.DB.Where("session_id = ? AND client_id = ?", sessionID, clientID).First(&existing).Error; err == nil {
+		respondSuccess(c, existing)
+		return
+	}
+
+	header, err := c.FormFile("file")
+	if err != nil {
+		respondError(c, http.StatusBadRequest, 400, "file is required")
+		return
+	}
+
+	// Storage quota check against incoming file size
+	var usedBytes int64
+	h.DB.Model(&models.MediaAsset{}).Where("user_id = ?", userID).Select("COALESCE(SUM(size_bytes), 0)").Scan(&usedBytes)
+	var setting models.SyncSetting
+	if err := h.DB.First(&setting, "user_id = ?", userID).Error; err != nil {
+		setting.CloudStorageMaxMB = 5120 // default 5 GB
+	}
+	maxBytes := setting.CloudStorageMaxMB * 1024 * 1024
+	remainingBytes := maxBytes - usedBytes
+	if remainingBytes < header.Size {
+		c.JSON(http.StatusRequestEntityTooLarge, gin.H{
+			"code": 413, "message": "storage quota exceeded",
+			"data": gin.H{
+				"totalBytes":     maxBytes,
+				"usedBytes":      usedBytes,
+				"remainingBytes": remainingBytes,
+				"requiredBytes":  header.Size,
+			},
+		})
+		return
+	}
+	input, err := header.Open()
+	if err != nil {
+		respondError(c, http.StatusBadRequest, 400, "failed to read uploaded file")
+		return
+	}
+	defer input.Close()
+
+	asset := models.MediaAsset{
+		SessionID:   sessionID,
+		UserID:      userID,
+		ClientID:    clientID,
+		Kind:        kind,
+		FileName:    filepath.Base(header.Filename),
+		MIMEType:    header.Header.Get("Content-Type"),
+		SizeBytes:   0,
+		SHA256:      "pending",
+		StoragePath: "pending",
+	}
+	if err := h.DB.Create(&asset).Error; err != nil {
+		if isDuplicateError(err) && h.DB.Where("session_id = ? AND client_id = ?", sessionID, clientID).First(&existing).Error == nil {
+			respondSuccess(c, existing)
+			return
+		}
+		respondError(c, http.StatusInternalServerError, 500, "failed to create asset")
+		return
+	}
+
+	directory := filepath.Join(h.Cfg.StoragePath, userID, sessionID)
+	if err := os.MkdirAll(directory, 0o750); err != nil {
+		h.DB.Delete(&asset)
+		respondError(c, http.StatusInternalServerError, 500, "failed to prepare asset storage")
+		return
+	}
+	extension := strings.ToLower(filepath.Ext(header.Filename))
+	if len(extension) > 12 {
+		extension = ""
+	}
+	storagePath := filepath.Join(directory, asset.ID+extension)
+	output, err := os.OpenFile(storagePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640)
+	if err != nil {
+		h.DB.Delete(&asset)
+		respondError(c, http.StatusInternalServerError, 500, "failed to create asset file")
+		return
+	}
+	hasher := sha256.New()
+	size, copyErr := io.Copy(io.MultiWriter(output, hasher), input)
+	closeErr := output.Close()
+	if copyErr != nil || closeErr != nil {
+		os.Remove(storagePath)
+		h.DB.Delete(&asset)
+		respondError(c, http.StatusInternalServerError, 500, "failed to store uploaded file")
+		return
+	}
+	asset.SizeBytes = size
+	asset.SHA256 = hex.EncodeToString(hasher.Sum(nil))
+	asset.StoragePath = storagePath
+	if err := h.DB.Save(&asset).Error; err != nil {
+		os.Remove(storagePath)
+		h.DB.Delete(&asset)
+		respondError(c, http.StatusInternalServerError, 500, "failed to finalize uploaded file")
+		return
+	}
+	// Increment session revision so other devices detect the new asset
+	h.DB.Model(&session).Updates(map[string]interface{}{
+		"revision":   gorm.Expr("revision + 1"),
+		"updated_at": gorm.Expr("NOW()"),
+	})
+	// Reload to get the updated revision
+	h.DB.First(&session, "id = ?", session.ID)
+	c.JSON(http.StatusOK, gin.H{
+		"code": 0, "message": "success",
+		"data": gin.H{
+			"asset":           asset,
+			"sessionRevision": session.Revision,
+		},
+	})
+}
+
+func (h *MediaHandler) DownloadAsset(c *gin.Context) {
+	userID := c.GetString("userID")
+	var asset models.MediaAsset
+	if err := h.DB.Where("id = ? AND session_id = ? AND user_id = ?", c.Param("assetId"), c.Param("id"), userID).First(&asset).Error; err != nil {
+		respondError(c, http.StatusNotFound, 404, "asset not found")
+		return
+	}
+	if _, err := os.Stat(asset.StoragePath); err != nil {
+		respondError(c, http.StatusNotFound, 404, "asset file is unavailable")
+		return
+	}
+	if asset.MIMEType != "" {
+		c.Header("Content-Type", asset.MIMEType)
+	}
+	c.FileAttachment(asset.StoragePath, asset.FileName)
+}
+
+// InitChunkedUpload creates a new chunked upload session for large files.
+func (h *MediaHandler) InitChunkedUpload(c *gin.Context) {
+	userID := c.GetString("userID")
+	sessionID := c.Param("id")
+	var session models.CelestiaSession
+	if err := h.DB.Where("id = ? AND user_id = ? AND deleted_at IS NULL", sessionID, userID).First(&session).Error; err != nil {
+		respondError(c, http.StatusNotFound, 404, "record not found")
+		return
+	}
+
+	var req struct {
+		ClientID  string `json:"clientId" binding:"required"`
+		Kind      string `json:"kind" binding:"required"`
+		FileName  string `json:"fileName" binding:"required"`
+		MIMEType  string `json:"mimeType"`
+		FileSize  int64  `json:"fileSize" binding:"required,gt=0"`
+		ChunkSize int    `json:"chunkSize" binding:"required,gt=0"`
+	}
+	if err := c.ShouldBindJSON(&req); err != nil {
+		respondError(c, http.StatusBadRequest, 400, "invalid request: "+err.Error())
+		return
+	}
+	req.Kind = strings.ToUpper(strings.TrimSpace(req.Kind))
+	if req.Kind != "AUDIO" && req.Kind != "PHOTO" {
+		respondError(c, http.StatusBadRequest, 400, "kind must be AUDIO or PHOTO")
+		return
+	}
+
+	// Storage quota pre-check against requested FileSize
+	var usedBytes int64
+	h.DB.Model(&models.MediaAsset{}).Where("user_id = ?", userID).Select("COALESCE(SUM(size_bytes), 0)").Scan(&usedBytes)
+	var setting models.SyncSetting
+	if err := h.DB.First(&setting, "user_id = ?", userID).Error; err != nil {
+		setting.CloudStorageMaxMB = 5120
+	}
+	maxBytes := setting.CloudStorageMaxMB * 1024 * 1024
+	remainingBytes := maxBytes - usedBytes
+	if remainingBytes < req.FileSize {
+		c.JSON(http.StatusRequestEntityTooLarge, gin.H{
+			"code": 413, "message": "storage quota exceeded",
+			"data": gin.H{
+				"totalBytes":     maxBytes,
+				"usedBytes":      usedBytes,
+				"remainingBytes": remainingBytes,
+				"requiredBytes":  req.FileSize,
+			},
+		})
+		return
+	}
+
+	totalChunks := int(math.Ceil(float64(req.FileSize) / float64(req.ChunkSize)))
+
+	storageDir := filepath.Join(h.Cfg.StoragePath, userID, sessionID, "chunks")
+	if err := os.MkdirAll(storageDir, 0o750); err != nil {
+		respondError(c, http.StatusInternalServerError, 500, "failed to prepare chunk storage")
+		return
+	}
+
+	upload := models.ChunkedUpload{
+		SessionID:      sessionID,
+		UserID:         userID,
+		ClientID:       strings.TrimSpace(req.ClientID),
+		Kind:           req.Kind,
+		FileName:       filepath.Base(req.FileName),
+		MIMEType:       req.MIMEType,
+		TotalSize:      req.FileSize,
+		ChunkSize:      req.ChunkSize,
+		TotalChunks:    totalChunks,
+		UploadedChunks: 0,
+		StorageDir:     storageDir,
+		Status:         "pending",
+		ExpiresAt:      time.Now().Add(24 * time.Hour),
+	}
+	if err := h.DB.Create(&upload).Error; err != nil {
+		respondError(c, http.StatusInternalServerError, 500, "failed to create chunked upload")
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"code": 0, "message": "success",
+		"data": gin.H{
+			"uploadId":    upload.ID,
+			"totalChunks": totalChunks,
+			"chunkSize":   req.ChunkSize,
+			"expiresAt":   upload.ExpiresAt,
+		},
+	})
+}
+
+// UploadChunk receives a single chunk of a chunked upload.
+func (h *MediaHandler) UploadChunk(c *gin.Context) {
+	userID := c.GetString("userID")
+	sessionID := c.Param("id")
+
+	uploadID := strings.TrimSpace(c.PostForm("uploadId"))
+	chunkIndexStr := strings.TrimSpace(c.PostForm("chunkIndex"))
+	if uploadID == "" || chunkIndexStr == "" {
+		respondError(c, http.StatusBadRequest, 400, "uploadId and chunkIndex are required")
+		return
+	}
+	chunkIndex, err := strconv.Atoi(chunkIndexStr)
+	if err != nil || chunkIndex < 0 {
+		respondError(c, http.StatusBadRequest, 400, "chunkIndex must be a non-negative integer")
+		return
+	}
+
+	var upload models.ChunkedUpload
+	if err := h.DB.Where("id = ? AND session_id = ? AND user_id = ? AND status = 'pending'", uploadID, sessionID, userID).First(&upload).Error; err != nil {
+		respondError(c, http.StatusNotFound, 404, "chunked upload not found")
+		return
+	}
+	if time.Now().After(upload.ExpiresAt) {
+		respondError(c, http.StatusGone, 410, "chunked upload has expired")
+		return
+	}
+	if chunkIndex >= upload.TotalChunks {
+		respondError(c, http.StatusBadRequest, 400, "chunkIndex out of range")
+		return
+	}
+
+	header, err := c.FormFile("file")
+	if err != nil {
+		respondError(c, http.StatusBadRequest, 400, "file is required")
+		return
+	}
+	input, err := header.Open()
+	if err != nil {
+		respondError(c, http.StatusBadRequest, 400, "failed to read uploaded chunk")
+		return
+	}
+	defer input.Close()
+
+	chunkPath := filepath.Join(upload.StorageDir, fmt.Sprintf("chunk_%04d", chunkIndex))
+	output, err := os.OpenFile(chunkPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640)
+	if err != nil {
+		respondError(c, http.StatusInternalServerError, 500, "failed to create chunk file")
+		return
+	}
+	if _, err := io.Copy(output, input); err != nil {
+		output.Close()
+		os.Remove(chunkPath)
+		respondError(c, http.StatusInternalServerError, 500, "failed to write chunk")
+		return
+	}
+	output.Close()
+
+	h.DB.Model(&upload).Update("uploaded_chunks", gorm.Expr("uploaded_chunks + 1"))
+
+	c.JSON(http.StatusOK, gin.H{
+		"code": 0, "message": "success",
+		"data": gin.H{
+			"uploadId":       upload.ID,
+			"chunkIndex":     chunkIndex,
+			"uploadedChunks": upload.UploadedChunks + 1,
+			"totalChunks":    upload.TotalChunks,
+		},
+	})
+}
+
+// CompleteChunkedUpload merges all chunks into a single file and creates the
+// MediaAsset record.
+func (h *MediaHandler) CompleteChunkedUpload(c *gin.Context) {
+	userID := c.GetString("userID")
+	sessionID := c.Param("id")
+
+	var req struct {
+		UploadID    string `json:"uploadId" binding:"required"`
+		TotalChunks int    `json:"totalChunks" binding:"required"`
+	}
+	if err := c.ShouldBindJSON(&req); err != nil {
+		respondError(c, http.StatusBadRequest, 400, "invalid request: "+err.Error())
+		return
+	}
+
+	var upload models.ChunkedUpload
+	if err := h.DB.Where("id = ? AND session_id = ? AND user_id = ? AND status = 'pending'", req.UploadID, sessionID, userID).First(&upload).Error; err != nil {
+		respondError(c, http.StatusNotFound, 404, "chunked upload not found")
+		return
+	}
+	if time.Now().After(upload.ExpiresAt) {
+		respondError(c, http.StatusGone, 410, "chunked upload has expired")
+		return
+	}
+	if req.TotalChunks != upload.TotalChunks {
+		respondError(c, http.StatusBadRequest, 400, "totalChunks mismatch")
+		return
+	}
+
+	// Verify all chunks exist
+	for i := 0; i < upload.TotalChunks; i++ {
+		chunkPath := filepath.Join(upload.StorageDir, fmt.Sprintf("chunk_%04d", i))
+		if _, err := os.Stat(chunkPath); err != nil {
+			respondError(c, http.StatusBadRequest, 400, fmt.Sprintf("chunk %d is missing", i))
+			return
+		}
+	}
+
+	// Merge chunks into final file
+	directory := filepath.Join(h.Cfg.StoragePath, userID, sessionID)
+	if err := os.MkdirAll(directory, 0o750); err != nil {
+		respondError(c, http.StatusInternalServerError, 500, "failed to prepare asset storage")
+		return
+	}
+	extension := strings.ToLower(filepath.Ext(upload.FileName))
+	if len(extension) > 12 {
+		extension = ""
+	}
+
+	// Create MediaAsset first to get the ID for the filename
+	asset := models.MediaAsset{
+		SessionID:   sessionID,
+		UserID:      userID,
+		ClientID:    upload.ClientID,
+		Kind:        upload.Kind,
+		FileName:    upload.FileName,
+		MIMEType:    upload.MIMEType,
+		SizeBytes:   0,
+		SHA256:      "pending",
+		StoragePath: "pending",
+	}
+	if err := h.DB.Create(&asset).Error; err != nil {
+		if isDuplicateError(err) {
+			var existing models.MediaAsset
+			if h.DB.Where("session_id = ? AND client_id = ?", sessionID, upload.ClientID).First(&existing).Error == nil {
+				respondSuccess(c, existing)
+				return
+			}
+		}
+		respondError(c, http.StatusInternalServerError, 500, "failed to create asset")
+		return
+	}
+
+	storagePath := filepath.Join(directory, asset.ID+extension)
+	output, err := os.OpenFile(storagePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640)
+	if err != nil {
+		h.DB.Delete(&asset)
+		respondError(c, http.StatusInternalServerError, 500, "failed to create asset file")
+		return
+	}
+
+	hasher := sha256.New()
+	var totalSize int64
+	for i := 0; i < upload.TotalChunks; i++ {
+		chunkPath := filepath.Join(upload.StorageDir, fmt.Sprintf("chunk_%04d", i))
+		chunkFile, err := os.Open(chunkPath)
+		if err != nil {
+			output.Close()
+			os.Remove(storagePath)
+			h.DB.Delete(&asset)
+			respondError(c, http.StatusInternalServerError, 500, "failed to read chunk")
+			return
+		}
+		n, copyErr := io.Copy(io.MultiWriter(output, hasher), chunkFile)
+		chunkFile.Close()
+		if copyErr != nil {
+			output.Close()
+			os.Remove(storagePath)
+			h.DB.Delete(&asset)
+			respondError(c, http.StatusInternalServerError, 500, "failed to merge chunk")
+			return
+		}
+		totalSize += n
+	}
+	if err := output.Close(); err != nil {
+		os.Remove(storagePath)
+		h.DB.Delete(&asset)
+		respondError(c, http.StatusInternalServerError, 500, "failed to finalize merged file")
+		return
+	}
+
+	asset.SizeBytes = totalSize
+	asset.SHA256 = hex.EncodeToString(hasher.Sum(nil))
+	asset.StoragePath = storagePath
+	if err := h.DB.Save(&asset).Error; err != nil {
+		os.Remove(storagePath)
+		h.DB.Delete(&asset)
+		respondError(c, http.StatusInternalServerError, 500, "failed to finalize asset")
+		return
+	}
+
+	// Clean up chunk files
+	for i := 0; i < upload.TotalChunks; i++ {
+		os.Remove(filepath.Join(upload.StorageDir, fmt.Sprintf("chunk_%04d", i)))
+	}
+	os.Remove(upload.StorageDir)
+
+	// Mark upload as completed
+	h.DB.Model(&upload).Update("status", "completed")
+
+	// Increment session revision
+	var session models.CelestiaSession
+	h.DB.First(&session, "id = ?", sessionID)
+	h.DB.Model(&session).Updates(map[string]interface{}{
+		"revision":   gorm.Expr("revision + 1"),
+		"updated_at": gorm.Expr("NOW()"),
+	})
+	h.DB.First(&session, "id = ?", session.ID)
+
+	c.JSON(http.StatusOK, gin.H{
+		"code": 0, "message": "success",
+		"data": gin.H{
+			"asset":           asset,
+			"sessionRevision": session.Revision,
+		},
+	})
+}

+ 256 - 0
handlers/session_handler.go

@@ -0,0 +1,256 @@
+package handlers
+
+import (
+	"errors"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/celestia-trace/backend/models"
+	"github.com/gin-gonic/gin"
+	"gorm.io/gorm"
+)
+
+type SessionHandler struct {
+	DB *gorm.DB
+}
+
+func sessionPreloads(db *gorm.DB) *gorm.DB {
+	return db.Preload("Events", func(events *gorm.DB) *gorm.DB {
+		return events.Order("relative_time_ms asc")
+	}).Preload("Assets")
+}
+
+// GetSessions supports cursor-like incremental pulls with updatedAfter and can
+// include deletion tombstones for multi-device reconciliation.
+func (h *SessionHandler) GetSessions(c *gin.Context) {
+	userID := c.GetString("userID")
+	query := sessionPreloads(h.DB).Where("user_id = ?", userID)
+	if c.Query("includeDeleted") != "true" {
+		query = query.Where("deleted_at IS NULL")
+	}
+	if search := c.Query("search"); search != "" {
+		query = query.Where("title ILIKE ?", "%"+search+"%")
+	}
+	if value := c.Query("updatedAfter"); value != "" {
+		updatedAfter, err := time.Parse(time.RFC3339Nano, value)
+		if err != nil {
+			respondError(c, http.StatusBadRequest, 400, "invalid updatedAfter timestamp")
+			return
+		}
+		query = query.Where("updated_at > ?", updatedAfter)
+	}
+	var sessions []models.CelestiaSession
+	if err := query.Order("updated_at asc, id asc").Find(&sessions).Error; err != nil {
+		respondError(c, http.StatusInternalServerError, 500, "failed to query field records")
+		return
+	}
+	respondSuccess(c, sessions)
+}
+
+func (h *SessionHandler) GetSessionDetail(c *gin.Context) {
+	userID := c.GetString("userID")
+	var session models.CelestiaSession
+	if err := sessionPreloads(h.DB).
+		Where("id = ? AND user_id = ? AND deleted_at IS NULL", c.Param("id"), userID).
+		First(&session).Error; err != nil {
+		respondError(c, http.StatusNotFound, 404, "record not found")
+		return
+	}
+	respondSuccess(c, session)
+}
+
+// CreateSession is an idempotent upsert keyed by (user_id, client_id).
+func (h *SessionHandler) CreateSession(c *gin.Context) {
+	userID := c.GetString("userID")
+	var req models.CreateSessionRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		respondError(c, http.StatusBadRequest, 400, "invalid input data")
+		return
+	}
+
+	var session models.CelestiaSession
+	var conflictRevision int64
+	var isConflict bool
+	err := h.DB.Transaction(func(tx *gorm.DB) error {
+		err := tx.Where("user_id = ? AND client_id = ?", userID, req.ClientID).First(&session).Error
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			clientID := req.ClientID
+			session = models.CelestiaSession{
+				UserID: userID, ClientID: &clientID, Revision: 1,
+			}
+		} else if err != nil {
+			return err
+		} else {
+			// Optimistic locking: reject if client baseRevision doesn't match
+			if req.BaseRevision != nil && *req.BaseRevision != session.Revision {
+				conflictRevision = session.Revision
+				isConflict = true
+				return nil // exit transaction without error; handled below
+			}
+			session.Revision++
+		}
+
+		session.Title = req.Title
+		session.StartTime = req.StartTime
+		session.EndTime = req.EndTime
+		session.DurationMs = req.DurationMs
+		session.LocalAudioPath = ""
+		session.IsSynced = true
+		session.DeletedAt = nil
+		session.PhotoCount, session.NoteCount = countEvents(req.Events)
+		if session.ID == "" {
+			if err := tx.Create(&session).Error; err != nil {
+				return err
+			}
+		} else if err := tx.Save(&session).Error; err != nil {
+			return err
+		}
+
+		for _, input := range req.Events {
+			var event models.TimelineEvent
+			err := tx.Where("session_id = ? AND client_id = ?", session.ID, input.ClientID).First(&event).Error
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				clientID := input.ClientID
+				event = models.TimelineEvent{SessionID: session.ID, ClientID: &clientID}
+			} else if err != nil {
+				return err
+			}
+			event.RelativeTimeMs = input.RelativeTimeMs
+			event.EventType = input.EventType
+			event.TextContent = input.TextContent
+			event.LocalFilePath = ""
+			event.VoiceStartOffsetMs = input.VoiceStartOffsetMs
+			event.VoiceEndOffsetMs = input.VoiceEndOffsetMs
+			if event.ID == "" {
+				if err := tx.Create(&event).Error; err != nil {
+					return err
+				}
+			} else if err := tx.Save(&event).Error; err != nil {
+				return err
+			}
+		}
+
+		// Delete events that were removed on the client
+		if len(req.DeletedEventClientIds) > 0 {
+			if err := tx.Where("session_id = ? AND client_id IN ?", session.ID, req.DeletedEventClientIds).
+				Delete(&models.TimelineEvent{}).Error; err != nil {
+				return err
+			}
+			// Recalculate counts from the remaining DB events
+			var remaining []models.TimelineEvent
+			if err := tx.Where("session_id = ?", session.ID).Find(&remaining).Error; err != nil {
+				return err
+			}
+			session.PhotoCount, session.NoteCount = countPersistedEvents(remaining)
+			if err := tx.Save(&session).Error; err != nil {
+				return err
+			}
+		}
+
+		return nil
+	})
+	if isConflict {
+		c.JSON(http.StatusConflict, gin.H{
+			"code": 409, "message": "version conflict",
+			"data": gin.H{"serverRevision": conflictRevision},
+		})
+		return
+	}
+	if err != nil {
+		respondError(c, http.StatusInternalServerError, 500, "failed to save record")
+		return
+	}
+	if err := sessionPreloads(h.DB).First(&session, "id = ?", session.ID).Error; err != nil {
+		respondError(c, http.StatusInternalServerError, 500, "failed to reload record")
+		return
+	}
+	respondSuccess(c, session)
+}
+
+func countEvents(events []models.CreateEventSubSchema) (photoCount, noteCount int) {
+	for _, event := range events {
+		switch event.EventType {
+		case "PHOTO":
+			photoCount++
+		case "NOTE":
+			noteCount++
+		case "MARKER":
+			// Exclude continuation markers (e.g. "续录时间:...") from note count
+			if !strings.HasPrefix(event.TextContent, "续录时间:") {
+				noteCount++
+			}
+		case "CONTINUATION":
+			// Dedicated continuation type, not counted as a note
+		}
+	}
+	return
+}
+
+// countPersistedEvents recalculates counts from persisted TimelineEvent rows
+// (used after event deletion to keep counts accurate).
+func countPersistedEvents(events []models.TimelineEvent) (photoCount, noteCount int) {
+	for _, event := range events {
+		switch event.EventType {
+		case "PHOTO":
+			photoCount++
+		case "NOTE":
+			noteCount++
+		case "MARKER":
+			if !strings.HasPrefix(event.TextContent, "续录时间:") {
+				noteCount++
+			}
+		case "CONTINUATION":
+			// Not counted
+		}
+	}
+	return
+}
+
+func (h *SessionHandler) AddEventToSession(c *gin.Context) {
+	userID := c.GetString("userID")
+	sessionID := c.Param("id")
+	var session models.CelestiaSession
+	if err := h.DB.Where("id = ? AND user_id = ? AND deleted_at IS NULL", sessionID, userID).First(&session).Error; err != nil {
+		respondError(c, http.StatusNotFound, 404, "record not found")
+		return
+	}
+	var req models.CreateEventRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		respondError(c, http.StatusBadRequest, 400, "invalid event payload")
+		return
+	}
+	clientID := req.ClientID
+	event := models.TimelineEvent{
+		SessionID: session.ID, ClientID: &clientID,
+		RelativeTimeMs: req.RelativeTimeMs, EventType: req.EventType,
+		TextContent: req.TextContent, VoiceStartOffsetMs: req.VoiceStartOffsetMs,
+		VoiceEndOffsetMs: req.VoiceEndOffsetMs,
+	}
+	if err := h.DB.Where("session_id = ? AND client_id = ?", session.ID, req.ClientID).
+		Assign(event).FirstOrCreate(&event).Error; err != nil {
+		respondError(c, http.StatusInternalServerError, 500, "failed to add event")
+		return
+	}
+	h.DB.Model(&session).Updates(map[string]interface{}{"revision": gorm.Expr("revision + 1")})
+	respondSuccess(c, event)
+}
+
+// DeleteSession creates a tombstone rather than hard deleting cloud data.
+func (h *SessionHandler) DeleteSession(c *gin.Context) {
+	userID := c.GetString("userID")
+	sessionID := c.Param("id")
+	now := time.Now().UTC()
+	result := h.DB.Model(&models.CelestiaSession{}).
+		Where("id = ? AND user_id = ? AND deleted_at IS NULL", sessionID, userID).
+		Updates(map[string]interface{}{"deleted_at": &now, "revision": gorm.Expr("revision + 1")})
+	if result.Error != nil {
+		respondError(c, http.StatusInternalServerError, 500, "failed to delete record")
+		return
+	}
+	if result.RowsAffected == 0 {
+		respondError(c, http.StatusNotFound, 404, "record not found")
+		return
+	}
+	respondSuccess(c, gin.H{"deletedId": sessionID, "deletedAt": now})
+}

+ 160 - 0
handlers/sync_handler.go

@@ -0,0 +1,160 @@
+package handlers
+
+import (
+	"errors"
+	"net/http"
+	"time"
+
+	"github.com/celestia-trace/backend/models"
+	"github.com/gin-gonic/gin"
+	"gorm.io/gorm"
+)
+
+type SyncHandler struct {
+	DB *gorm.DB
+}
+
+func defaultSyncSetting(userID string) models.SyncSetting {
+	return models.SyncSetting{
+		UserID:             userID,
+		AutoSyncWiFi:       true,
+		SyncPhotos:         true,
+		SyncAudio:          true,
+		AudioQuality:       "HD",
+		CloudStorageMaxMB:  5120,
+		CloudStorageUsedMB: 0,
+	}
+}
+
+// GetSyncSettings returns the user sync settings and storage status
+func (h *SyncHandler) GetSyncSettings(c *gin.Context) {
+	userID := c.GetString("userID")
+
+	var setting models.SyncSetting
+	err := h.DB.First(&setting, "user_id = ?", userID).Error
+	if errors.Is(err, gorm.ErrRecordNotFound) {
+		setting = defaultSyncSetting(userID)
+		h.DB.Create(&setting)
+	}
+	var totalBytes int64
+	h.DB.Model(&models.MediaAsset{}).Where("user_id = ?", userID).Select("COALESCE(SUM(size_bytes), 0)").Scan(&totalBytes)
+	setting.CloudStorageUsedMB = (totalBytes + 1024*1024 - 1) / (1024 * 1024)
+
+	respondSuccess(c, setting)
+}
+
+// UpdateSyncSettings updates sync settings
+func (h *SyncHandler) UpdateSyncSettings(c *gin.Context) {
+	userID := c.GetString("userID")
+	var req models.UpdateSyncSettingRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		respondError(c, http.StatusBadRequest, 400, "invalid parameters")
+		return
+	}
+
+	var setting models.SyncSetting
+	if err := h.DB.First(&setting, "user_id = ?", userID).Error; err != nil {
+		setting = defaultSyncSetting(userID)
+	}
+
+	if req.AutoSyncWiFi != nil {
+		setting.AutoSyncWiFi = *req.AutoSyncWiFi
+	}
+	if req.SyncPhotos != nil {
+		setting.SyncPhotos = *req.SyncPhotos
+	}
+	if req.SyncAudio != nil {
+		setting.SyncAudio = *req.SyncAudio
+	}
+	if req.AudioQuality != nil {
+		setting.AudioQuality = *req.AudioQuality
+	}
+
+	now := time.Now()
+	setting.LastSyncedAt = &now
+
+	if err := h.DB.Save(&setting).Error; err != nil {
+		respondError(c, http.StatusInternalServerError, 500, "failed to update sync settings")
+		return
+	}
+
+	respondSuccess(c, setting)
+}
+
+// GetSyncSummary returns aggregated stats (devices, sessions, total duration, storage)
+func (h *SyncHandler) GetSyncSummary(c *gin.Context) {
+	userID := c.GetString("userID")
+
+	var deviceCount int64
+	h.DB.Model(&models.BoundDevice{}).Where("user_id = ?", userID).Count(&deviceCount)
+
+	var sessionCount int64
+	h.DB.Model(&models.CelestiaSession{}).Where("user_id = ? AND deleted_at IS NULL", userID).Count(&sessionCount)
+
+	var totalDurationMs int64
+	h.DB.Model(&models.CelestiaSession{}).Where("user_id = ? AND deleted_at IS NULL", userID).Select("COALESCE(SUM(duration_ms), 0)").Scan(&totalDurationMs)
+
+	var setting models.SyncSetting
+	if err := h.DB.First(&setting, "user_id = ?", userID).Error; err != nil {
+		setting = defaultSyncSetting(userID)
+		h.DB.Create(&setting)
+	}
+	var totalBytes int64
+	h.DB.Model(&models.MediaAsset{}).Where("user_id = ?", userID).Select("COALESCE(SUM(size_bytes), 0)").Scan(&totalBytes)
+	setting.CloudStorageUsedMB = (totalBytes + 1024*1024 - 1) / (1024 * 1024)
+
+	summary := models.SyncSummary{
+		DeviceCount:     deviceCount,
+		SessionCount:    sessionCount,
+		TotalDurationMs: totalDurationMs,
+		Storage:         setting,
+	}
+
+	respondSuccess(c, summary)
+}
+
+// TriggerManualSync records a completed client checkpoint. It does not claim
+// that local files were uploaded; the client only calls this after all uploads.
+func (h *SyncHandler) TriggerManualSync(c *gin.Context) {
+	userID := c.GetString("userID")
+	now := time.Now()
+
+	var setting models.SyncSetting
+	if err := h.DB.First(&setting, "user_id = ?", userID).Error; err == nil {
+		setting.LastSyncedAt = &now
+		h.DB.Save(&setting)
+	}
+
+	respondSuccess(c, gin.H{
+		"syncedAt": now,
+		"message":  "Client sync checkpoint recorded",
+	})
+}
+
+// GetStorageQuota returns the user's storage quota and current usage in bytes.
+// The iOS client calls GET /storage/quota before uploading large files.
+func (h *SyncHandler) GetStorageQuota(c *gin.Context) {
+	userID := c.GetString("userID")
+
+	var setting models.SyncSetting
+	if err := h.DB.First(&setting, "user_id = ?", userID).Error; err != nil {
+		setting = defaultSyncSetting(userID)
+		h.DB.Create(&setting)
+	}
+
+	var usedBytes int64
+	h.DB.Model(&models.MediaAsset{}).Where("user_id = ?", userID).
+		Select("COALESCE(SUM(size_bytes), 0)").Scan(&usedBytes)
+
+	totalBytes := setting.CloudStorageMaxMB * 1024 * 1024
+	remainingBytes := totalBytes - usedBytes
+	if remainingBytes < 0 {
+		remainingBytes = 0
+	}
+
+	respondSuccess(c, gin.H{
+		"totalBytes":     totalBytes,
+		"usedBytes":      usedBytes,
+		"remainingBytes": remainingBytes,
+	})
+}

+ 4 - 1
main.go

@@ -21,7 +21,10 @@ func main() {
 
 	// Load configuration
 	cfg := config.Load()
-	
+	if err := cfg.Validate(); err != nil {
+		log.Fatalf("Invalid configuration: %v", err)
+	}
+
 	// Initialize database
 	database.Init(cfg)
 

+ 5 - 2
middleware/auth.go

@@ -28,11 +28,11 @@ func Auth(cfg *config.Config) gin.HandlerFunc {
 
 		tokenString := parts[1]
 		token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
-			if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
+			if token.Method.Alg() != jwt.SigningMethodHS256.Alg() {
 				return nil, jwt.ErrSignatureInvalid
 			}
 			return []byte(cfg.JWTSecret), nil
-		})
+		}, jwt.WithIssuer("celestia-trace"), jwt.WithExpirationRequired())
 
 		if err != nil || !token.Valid {
 			c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid token"})
@@ -56,6 +56,9 @@ func Auth(cfg *config.Config) gin.HandlerFunc {
 
 		// Set user ID for downstream handlers
 		c.Set("userID", userID)
+		if sessionID, ok := claims["sid"].(string); ok {
+			c.Set("sessionID", sessionID)
+		}
 		c.Next()
 	}
 }

+ 23 - 0
models/chunked_upload.go

@@ -0,0 +1,23 @@
+package models
+
+import "time"
+
+// ChunkedUpload tracks a multi-part upload session. Chunks are stored as
+// individual files and merged when the client calls the complete endpoint.
+type ChunkedUpload struct {
+	ID             string    `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+	SessionID      string    `gorm:"type:uuid;not null;index" json:"sessionId"`
+	UserID         string    `gorm:"type:uuid;not null;index" json:"userId"`
+	ClientID       string    `gorm:"size:120" json:"clientId"`
+	Kind           string    `gorm:"size:20;not null" json:"kind"`
+	FileName       string    `gorm:"size:255;not null" json:"fileName"`
+	MIMEType       string    `gorm:"size:120" json:"mimeType"`
+	TotalSize      int64     `gorm:"not null" json:"totalSize"`
+	ChunkSize      int       `gorm:"not null" json:"chunkSize"`
+	TotalChunks    int       `gorm:"not null" json:"totalChunks"`
+	UploadedChunks int       `gorm:"default:0" json:"uploadedChunks"`
+	StorageDir     string    `gorm:"size:1000;not null" json:"-"`
+	Status         string    `gorm:"size:20;default:'pending'" json:"status"` // pending, completed, expired
+	CreatedAt      time.Time `gorm:"autoCreateTime" json:"createdAt"`
+	ExpiresAt      time.Time `gorm:"not null" json:"expiresAt"`
+}

+ 40 - 0
models/device.go

@@ -0,0 +1,40 @@
+package models
+
+import (
+	"time"
+)
+
+// BoundDevice represents a physical Spark ("微光") recording device bound to a user
+type BoundDevice struct {
+	ID              string    `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+	UserID          string    `gorm:"type:uuid;not null;index;uniqueIndex:idx_device_user_peripheral" json:"userId"`
+	Name            string    `gorm:"size:100;not null" json:"name"`
+	PeripheralUUID  string    `gorm:"size:100;uniqueIndex:idx_device_user_peripheral" json:"peripheralUUID"`
+	HardwareMAC     string    `gorm:"size:100" json:"hardwareMAC"`
+	BatteryLevel    int       `gorm:"default:100" json:"batteryLevel"`
+	FirmwareVersion string    `gorm:"size:50;default:'v1.2.0'" json:"firmwareVersion"`
+	FreeStorageMB   int       `gorm:"default:12000" json:"freeStorageMB"`
+	TotalStorageMB  int       `gorm:"default:16000" json:"totalStorageMB"`
+	IsConnected     bool      `gorm:"default:false" json:"isConnected"`
+	BoundAt         time.Time `gorm:"autoCreateTime" json:"boundAt"`
+	UpdatedAt       time.Time `gorm:"autoUpdateTime" json:"updatedAt"`
+}
+
+type BindDeviceRequest struct {
+	Name            string `json:"name" binding:"required"`
+	PeripheralUUID  string `json:"peripheralUUID"`
+	HardwareMAC     string `json:"hardwareMAC"`
+	FirmwareVersion string `json:"firmwareVersion"`
+	BatteryLevel    *int   `json:"batteryLevel,omitempty"`
+	FreeStorageMB   *int   `json:"freeStorageMB,omitempty"`
+	TotalStorageMB  *int   `json:"totalStorageMB,omitempty"`
+}
+
+type UpdateDeviceRequest struct {
+	Name            *string `json:"name,omitempty"`
+	BatteryLevel    *int    `json:"batteryLevel,omitempty"`
+	IsConnected     *bool   `json:"isConnected,omitempty"`
+	FirmwareVersion *string `json:"firmwareVersion,omitempty"`
+	FreeStorageMB   *int    `json:"freeStorageMB,omitempty"`
+	TotalStorageMB  *int    `json:"totalStorageMB,omitempty"`
+}

+ 88 - 0
models/session.go

@@ -0,0 +1,88 @@
+package models
+
+import (
+	"time"
+)
+
+// CelestiaSession represents a field record ("现场记录") session
+type CelestiaSession struct {
+	ID             string          `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+	UserID         string          `gorm:"type:uuid;not null;index;uniqueIndex:idx_session_user_client" json:"userId"`
+	ClientID       *string         `gorm:"size:100;uniqueIndex:idx_session_user_client" json:"clientId,omitempty"`
+	Title          string          `gorm:"size:200;not null" json:"title"`
+	StartTime      time.Time       `gorm:"not null" json:"startTime"`
+	EndTime        *time.Time      `json:"endTime,omitempty"`
+	DurationMs     int64           `gorm:"default:0" json:"durationMs"`
+	LocalAudioPath string          `gorm:"size:500" json:"localAudioPath,omitempty"`
+	IsSynced       bool            `gorm:"default:true" json:"isSynced"`
+	PhotoCount     int             `gorm:"default:0" json:"photoCount"`
+	NoteCount      int             `gorm:"default:0" json:"noteCount"`
+	Events         []TimelineEvent `gorm:"foreignKey:SessionID;constraint:OnDelete:CASCADE" json:"events"`
+	Assets         []MediaAsset    `gorm:"foreignKey:SessionID;constraint:OnDelete:CASCADE" json:"assets"`
+	Revision       int64           `gorm:"default:1" json:"revision"`
+	DeletedAt      *time.Time      `gorm:"index" json:"deletedAt,omitempty"`
+	CreatedAt      time.Time       `gorm:"autoCreateTime" json:"createdAt"`
+	UpdatedAt      time.Time       `gorm:"autoUpdateTime" json:"updatedAt"`
+}
+
+// TimelineEvent represents an event (NOTE, PHOTO, MARKER, VOICE) within a session
+type TimelineEvent struct {
+	ID                 string    `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+	SessionID          string    `gorm:"type:uuid;not null;index;uniqueIndex:idx_event_session_client" json:"sessionId"`
+	ClientID           *string   `gorm:"size:100;uniqueIndex:idx_event_session_client" json:"clientId,omitempty"`
+	RelativeTimeMs     int64     `gorm:"not null" json:"relativeTimeMs"`
+	EventType          string    `gorm:"size:20;not null" json:"eventType"` // PHOTO, NOTE, MARKER, VOICE
+	TextContent        string    `gorm:"type:text" json:"textContent,omitempty"`
+	LocalFilePath      string    `gorm:"size:500" json:"localFilePath,omitempty"`
+	VoiceStartOffsetMs *int64    `json:"voiceStartOffsetMs,omitempty"`
+	VoiceEndOffsetMs   *int64    `json:"voiceEndOffsetMs,omitempty"`
+	CreatedAt          time.Time `gorm:"autoCreateTime" json:"createdAt"`
+}
+
+// MediaAsset is an uploaded audio or photo belonging to a session. StoragePath
+// is never serialized because it is an implementation detail of the server.
+type MediaAsset struct {
+	ID          string    `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+	SessionID   string    `gorm:"type:uuid;not null;index;uniqueIndex:idx_asset_session_client" json:"sessionId"`
+	UserID      string    `gorm:"type:uuid;not null;index" json:"userId"`
+	ClientID    string    `gorm:"size:120;not null;uniqueIndex:idx_asset_session_client" json:"clientId"`
+	Kind        string    `gorm:"size:20;not null" json:"kind"`
+	FileName    string    `gorm:"size:255;not null" json:"fileName"`
+	MIMEType    string    `gorm:"size:120" json:"mimeType"`
+	SizeBytes   int64     `gorm:"not null" json:"sizeBytes"`
+	SHA256      string    `gorm:"size:64;not null;index" json:"sha256"`
+	StoragePath string    `gorm:"size:1000;not null" json:"-"`
+	CreatedAt   time.Time `gorm:"autoCreateTime" json:"createdAt"`
+}
+
+type CreateSessionRequest struct {
+	ClientID              string                 `json:"clientId" binding:"required"`
+	Title                 string                 `json:"title" binding:"required"`
+	StartTime             time.Time              `json:"startTime" binding:"required"`
+	EndTime               *time.Time             `json:"endTime"`
+	DurationMs            int64                  `json:"durationMs"`
+	LocalAudioPath        string                 `json:"localAudioPath"`
+	Events                []CreateEventSubSchema `json:"events"`
+	BaseRevision          *int64                 `json:"baseRevision"`
+	DeletedEventClientIds []string               `json:"deletedEventClientIds"`
+}
+
+type CreateEventSubSchema struct {
+	ClientID           string `json:"clientId" binding:"required"`
+	RelativeTimeMs     int64  `json:"relativeTimeMs"`
+	EventType          string `json:"eventType" binding:"required"`
+	TextContent        string `json:"textContent"`
+	LocalFilePath      string `json:"localFilePath"`
+	VoiceStartOffsetMs *int64 `json:"voiceStartOffsetMs"`
+	VoiceEndOffsetMs   *int64 `json:"voiceEndOffsetMs"`
+}
+
+type CreateEventRequest struct {
+	ClientID           string `json:"clientId" binding:"required"`
+	RelativeTimeMs     int64  `json:"relativeTimeMs"`
+	EventType          string `json:"eventType" binding:"required"`
+	TextContent        string `json:"textContent"`
+	LocalFilePath      string `json:"localFilePath"`
+	VoiceStartOffsetMs *int64 `json:"voiceStartOffsetMs"`
+	VoiceEndOffsetMs   *int64 `json:"voiceEndOffsetMs"`
+}

+ 32 - 0
models/sync.go

@@ -0,0 +1,32 @@
+package models
+
+import (
+	"time"
+)
+
+// SyncSetting represents user sync configurations and cloud storage allocation
+type SyncSetting struct {
+	UserID             string     `gorm:"type:uuid;primaryKey" json:"userId"`
+	AutoSyncWiFi       bool       `gorm:"default:true" json:"autoSyncWiFi"`
+	SyncPhotos         bool       `gorm:"default:true" json:"syncPhotos"`
+	SyncAudio          bool       `gorm:"default:true" json:"syncAudio"`
+	AudioQuality       string     `gorm:"size:20;default:'HD'" json:"audioQuality"` // Standard, HD, Lossless
+	CloudStorageMaxMB  int64      `gorm:"default:5120" json:"cloudStorageMaxMB"`    // 5 GB default
+	CloudStorageUsedMB int64      `gorm:"default:0" json:"cloudStorageUsedMB"`
+	LastSyncedAt       *time.Time `json:"lastSyncedAt,omitempty"`
+	UpdatedAt          time.Time  `gorm:"autoUpdateTime" json:"updatedAt"`
+}
+
+type UpdateSyncSettingRequest struct {
+	AutoSyncWiFi *bool   `json:"autoSyncWiFi,omitempty"`
+	SyncPhotos   *bool   `json:"syncPhotos,omitempty"`
+	SyncAudio    *bool   `json:"syncAudio,omitempty"`
+	AudioQuality *string `json:"audioQuality,omitempty"`
+}
+
+type SyncSummary struct {
+	DeviceCount     int64       `json:"deviceCount"`
+	SessionCount    int64       `json:"sessionCount"`
+	TotalDurationMs int64       `json:"totalDurationMs"`
+	Storage         SyncSetting `json:"storage"`
+}

+ 20 - 2
models/user.go

@@ -16,6 +16,18 @@ type User struct {
 	UpdatedAt    time.Time `gorm:"autoUpdateTime" json:"updatedAt"`
 }
 
+// AuthSession stores a revocable, hashed refresh token. Access tokens are
+// short-lived JWTs and reference this row through their sid claim.
+type AuthSession struct {
+	ID          string     `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+	UserID      string     `gorm:"type:uuid;not null;index" json:"userId"`
+	RefreshHash string     `gorm:"uniqueIndex;not null;size:64" json:"-"`
+	ExpiresAt   time.Time  `gorm:"not null;index" json:"expiresAt"`
+	RevokedAt   *time.Time `gorm:"index" json:"-"`
+	LastUsedAt  *time.Time `json:"lastUsedAt,omitempty"`
+	CreatedAt   time.Time  `gorm:"autoCreateTime" json:"createdAt"`
+}
+
 // RegisterRequest DTO
 type RegisterRequest struct {
 	Username   string `json:"username" binding:"required,min=2"`
@@ -45,8 +57,14 @@ type ChangePasswordRequest struct {
 
 // AuthResponse DTO
 type AuthResponse struct {
-	User  User   `json:"user"`
-	Token string `json:"token"`
+	User         User      `json:"user"`
+	Token        string    `json:"token"`
+	RefreshToken string    `json:"refreshToken"`
+	ExpiresAt    time.Time `json:"expiresAt"`
+}
+
+type RefreshRequest struct {
+	RefreshToken string `json:"refreshToken" binding:"required"`
 }
 
 // ErrorResponse DTO

+ 66 - 1
router/router.go

@@ -1,7 +1,9 @@
 package router
 
 import (
+	"context"
 	"net/http"
+	"time"
 
 	"github.com/celestia-trace/backend/config"
 	"github.com/celestia-trace/backend/handlers"
@@ -30,6 +32,19 @@ func Setup(db *gorm.DB, cfg *config.Config) *gin.Engine {
 	v1 := r.Group("/v1")
 	{
 		v1.GET("/health", func(c *gin.Context) {
+			sqlDB, err := db.DB()
+			if err != nil {
+				c.JSON(http.StatusServiceUnavailable, gin.H{"code": 1, "message": "database unavailable"})
+				return
+			}
+
+			ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
+			defer cancel()
+			if err := sqlDB.PingContext(ctx); err != nil {
+				c.JSON(http.StatusServiceUnavailable, gin.H{"code": 1, "message": "database unavailable"})
+				return
+			}
+
 			c.JSON(http.StatusOK, gin.H{"code": 0, "message": "success", "data": "healthy"})
 		})
 
@@ -39,7 +54,8 @@ func Setup(db *gorm.DB, cfg *config.Config) *gin.Engine {
 		{
 			authRoutes.POST("/register", authHandler.Register)
 			authRoutes.POST("/login", authHandler.Login)
-			
+			authRoutes.POST("/refresh", authHandler.Refresh)
+
 			// Auth required
 			authRoutes.Use(middleware.Auth(cfg))
 			authRoutes.POST("/logout", authHandler.Logout)
@@ -52,6 +68,55 @@ func Setup(db *gorm.DB, cfg *config.Config) *gin.Engine {
 			userRoutes.PUT("/profile", authHandler.UpdateProfile)
 			userRoutes.POST("/change-password", authHandler.ChangePassword)
 		}
+
+		// Device management routes
+		deviceHandler := handlers.DeviceHandler{DB: db}
+		deviceRoutes := v1.Group("/devices")
+		deviceRoutes.Use(middleware.Auth(cfg))
+		{
+			deviceRoutes.GET("", deviceHandler.GetDevices)
+			deviceRoutes.POST("", deviceHandler.BindDevice)
+			deviceRoutes.PUT("/:id", deviceHandler.UpdateDevice)
+			deviceRoutes.DELETE("/:id", deviceHandler.UnbindDevice)
+		}
+
+		// Session / Field Record routes
+		sessionHandler := handlers.SessionHandler{DB: db}
+		mediaHandler := handlers.MediaHandler{DB: db, Cfg: cfg}
+		sessionRoutes := v1.Group("/sessions")
+		sessionRoutes.Use(middleware.Auth(cfg))
+		{
+			sessionRoutes.GET("", sessionHandler.GetSessions)
+			sessionRoutes.POST("", sessionHandler.CreateSession)
+			sessionRoutes.GET("/:id", sessionHandler.GetSessionDetail)
+			sessionRoutes.POST("/:id/events", sessionHandler.AddEventToSession)
+			sessionRoutes.DELETE("/:id", sessionHandler.DeleteSession)
+			sessionRoutes.POST("/:id/assets", mediaHandler.UploadAsset)
+			sessionRoutes.GET("/:id/assets/:assetId", mediaHandler.DownloadAsset)
+
+			// Chunked upload routes
+			sessionRoutes.POST("/:id/assets/init", mediaHandler.InitChunkedUpload)
+			sessionRoutes.POST("/:id/assets/chunk", mediaHandler.UploadChunk)
+			sessionRoutes.POST("/:id/assets/complete", mediaHandler.CompleteChunkedUpload)
+		}
+
+		// Sync settings & summary routes
+		syncHandler := handlers.SyncHandler{DB: db}
+		syncRoutes := v1.Group("/sync")
+		syncRoutes.Use(middleware.Auth(cfg))
+		{
+			syncRoutes.GET("/settings", syncHandler.GetSyncSettings)
+			syncRoutes.PUT("/settings", syncHandler.UpdateSyncSettings)
+			syncRoutes.GET("/summary", syncHandler.GetSyncSummary)
+			syncRoutes.POST("/trigger", syncHandler.TriggerManualSync)
+		}
+
+		// Storage quota route
+		storageRoutes := v1.Group("/storage")
+		storageRoutes.Use(middleware.Auth(cfg))
+		{
+			storageRoutes.GET("/quota", syncHandler.GetStorageQuota)
+		}
 	}
 
 	return r