All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m44s
- Fix auth bypass in UpdatePost/DeletePost (missing return after auth check) - Remove Spotify access token from callback response - Replace internal error messages with generic responses in all handlers - Harden GraphQL: complexity limit, disable playground/introspection in prod - Add security headers (X-Frame-Options, HSTS, etc.) to nginx - Disable Hasura console/dev mode in production - Add DOMPurify sanitization to Markdown component - Fix cookie removal to use correct domain/path from auth config - Fix nil dereference in rowing handler when Claude API errors - Fix wildcard CORS on stamp endpoint - Pin nginx and certbot Docker image versions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
197 lines
5.4 KiB
Go
197 lines
5.4 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/rwcarlsen/goexif/exif"
|
|
|
|
"adam-french.co.uk/backend/models"
|
|
"github.com/anthropics/anthropic-sdk-go"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
type ExtractedRowingData struct {
|
|
TimeMinutes uint64 `json:"timeMinutes"`
|
|
TimeSeconds uint64 `json:"timeSeconds"`
|
|
Distance uint64 `json:"distance"`
|
|
}
|
|
|
|
func (store *Store) GetRowing(ctx *gin.Context) {
|
|
var rowing []models.Rowing
|
|
if err := store.DB.Order("Created_At DESC").Find(&rowing).Error; err != nil {
|
|
log.Println(err)
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, rowing)
|
|
}
|
|
|
|
func (store *Store) CreateRowing(ctx *gin.Context) {
|
|
file, err := ctx.FormFile("image")
|
|
if err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "image is required"})
|
|
return
|
|
}
|
|
|
|
f, err := file.Open()
|
|
if err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open image"})
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
// Get the date taken from the EXIF data
|
|
x, err := exif.Decode(f)
|
|
if err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "no EXIF data found"})
|
|
return
|
|
}
|
|
|
|
dateTaken, err := x.DateTime()
|
|
if err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "no date found in EXIF data"})
|
|
return
|
|
}
|
|
|
|
// Seek back to start since exif.Decode advanced the cursor
|
|
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to seek image"})
|
|
return
|
|
}
|
|
|
|
data, err := io.ReadAll(f)
|
|
if err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read image"})
|
|
return
|
|
}
|
|
|
|
allowedMediaTypes := map[string]bool{
|
|
"image/jpeg": true,
|
|
"image/png": true,
|
|
"image/gif": true,
|
|
"image/webp": true,
|
|
}
|
|
mediaType := file.Header.Get("Content-Type")
|
|
if !allowedMediaTypes[mediaType] {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "unsupported image type"})
|
|
return
|
|
}
|
|
encoded := base64.StdEncoding.EncodeToString(data)
|
|
|
|
// Reject duplicates: same EXIF datetime already recorded
|
|
var existing models.Rowing
|
|
if err := store.DB.Where("date = ?", dateTaken).First(&existing).Error; err == nil {
|
|
ctx.JSON(http.StatusConflict, gin.H{"error": "duplicate entry for this date"})
|
|
return
|
|
}
|
|
|
|
// Build the message with an image + text prompt
|
|
message, err := store.ClaudeClient.Messages.New(context.Background(), anthropic.MessageNewParams{
|
|
Model: anthropic.ModelClaudeHaiku4_5,
|
|
MaxTokens: 256,
|
|
Messages: []anthropic.MessageParam{
|
|
{
|
|
Role: "user",
|
|
Content: []anthropic.ContentBlockParamUnion{
|
|
// Image block
|
|
anthropic.NewImageBlock(anthropic.Base64ImageSourceParam{
|
|
Type: "base64",
|
|
MediaType: anthropic.Base64ImageSourceMediaType(mediaType),
|
|
Data: encoded,
|
|
}),
|
|
// Text prompt requesting exactly 2 variables
|
|
anthropic.NewTextBlock(
|
|
`Look at this rowing machine display. Extract the total elapsed time and total distance.
|
|
|
|
Return ONLY a JSON object with these exact keys and numeric values:
|
|
- "timeMinutes": total minutes (e.g. 2:30 = 2)
|
|
- "timeSeconds": total seconds (e.g. 2:30 = 30)
|
|
- "distance": distance in meters as a number (e.g. 5000)
|
|
|
|
No text, no markdown, no explanation. Just the JSON object.`),
|
|
},
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
log.Println(err)
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process image"})
|
|
return
|
|
}
|
|
|
|
if len(message.Content) == 0 {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "empty response from image processor"})
|
|
return
|
|
}
|
|
|
|
extractedData := ExtractedRowingData{}
|
|
raw := message.Content[0].Text
|
|
|
|
raw = strings.TrimSpace(raw)
|
|
raw = strings.TrimPrefix(raw, "```json")
|
|
raw = strings.TrimPrefix(raw, "```")
|
|
raw = strings.TrimSuffix(raw, "```")
|
|
raw = strings.TrimSpace(raw)
|
|
|
|
err = json.Unmarshal([]byte(raw), &extractedData)
|
|
if err != nil {
|
|
log.Println(err)
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse image data"})
|
|
return
|
|
}
|
|
|
|
if extractedData.Distance == 0 {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid distance in image"})
|
|
return
|
|
}
|
|
|
|
totalSeconds := extractedData.TimeMinutes*60 + extractedData.TimeSeconds
|
|
|
|
// Validate for anomalous values
|
|
const (
|
|
minDistance = 100 // metres
|
|
maxDistance = 100000 // metres
|
|
minTotalSecs = 30 // 30 seconds
|
|
maxTotalSecs = 7200 // 2 hours
|
|
minPacePer500m = 80 // ~1:20 /500m (faster than any human)
|
|
maxPacePer500m = 150 // ~2:30 /500m (slow, not important)
|
|
)
|
|
if extractedData.Distance < minDistance || extractedData.Distance > maxDistance {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "anomalous distance value"})
|
|
return
|
|
}
|
|
if totalSeconds < minTotalSecs || totalSeconds > maxTotalSecs {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "anomalous time value"})
|
|
return
|
|
}
|
|
|
|
per500m := float64(totalSeconds) / float64(extractedData.Distance) * 500.0
|
|
if per500m < minPacePer500m || per500m > maxPacePer500m {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "anomalous pace value"})
|
|
return
|
|
}
|
|
|
|
calories := float64(extractedData.Distance) / 7500.0 * 500.0
|
|
|
|
rowing := models.Rowing{
|
|
Date: dateTaken,
|
|
Time: totalSeconds,
|
|
TimePer500m: per500m,
|
|
Distance: extractedData.Distance,
|
|
Calories: calories,
|
|
}
|
|
|
|
if err := store.DB.Create(&rowing).Error; err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save rowing"})
|
|
return
|
|
}
|
|
|
|
ctx.JSON(http.StatusCreated, rowing)
|
|
}
|