From 3f11bad8f637af50af53cfc12b3ad4c2d49e06ce Mon Sep 17 00:00:00 2001 From: Julian Mutter Date: Fri, 23 Jan 2026 20:05:23 +0100 Subject: [PATCH] First setup of a go server --- database/connection.go | 27 ++++++++ handlers/auth.go | 96 ++++++++++++++++++++++++++ handlers/sheets.go | 150 +++++++++++++++++++++++++++++++++++++++++ main.go | 22 ++++++ middleware/auth.go | 50 ++++++++++++++ models/sheet.go | 21 ++++++ models/user.go | 17 +++++ routes/routes.go | 29 ++++++++ 8 files changed, 412 insertions(+) create mode 100644 database/connection.go create mode 100644 handlers/auth.go create mode 100644 handlers/sheets.go create mode 100644 main.go create mode 100644 middleware/auth.go create mode 100644 models/sheet.go create mode 100644 models/user.go create mode 100644 routes/routes.go diff --git a/database/connection.go b/database/connection.go new file mode 100644 index 0000000..1fd4ee0 --- /dev/null +++ b/database/connection.go @@ -0,0 +1,27 @@ +package database + +import ( + "log" + "sheetless-server/models" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +var DB *gorm.DB + +func InitDatabase() { + var err error + DB, err = gorm.Open(sqlite.Open("sheetless.db"), &gorm.Config{}) + if err != nil { + log.Fatal("Failed to connect to database:", err) + } + + // Auto migrate the schema + err = DB.AutoMigrate(&models.User{}, &models.MusicSheet{}) + if err != nil { + log.Fatal("Failed to migrate database:", err) + } + + log.Println("Database connected and migrated successfully") +} \ No newline at end of file diff --git a/handlers/auth.go b/handlers/auth.go new file mode 100644 index 0000000..7246e4c --- /dev/null +++ b/handlers/auth.go @@ -0,0 +1,96 @@ +package handlers + +import ( + "net/http" + "sheetless-server/database" + "sheetless-server/models" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +var jwtSecret = []byte("your-secret-key") // In production, use environment variable + +type RegisterRequest struct { + Username string `json:"username" binding:"required"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=6"` +} + +type LoginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +func Register(c *gin.Context) { + var req RegisterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Check if user already exists + var existingUser models.User + if err := database.DB.Where("username = ? OR email = ?", req.Username, req.Email).First(&existingUser).Error; err == nil { + c.JSON(http.StatusConflict, gin.H{"error": "User already exists"}) + return + } + + // Hash password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"}) + return + } + + // Create user + user := models.User{ + Username: req.Username, + Email: req.Email, + Password: string(hashedPassword), + } + + if err := database.DB.Create(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"message": "User created successfully"}) +} + +func Login(c *gin.Context) { + var req LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Find user + var user models.User + if err := database.DB.Where("username = ?", req.Username).First(&user).Error; err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) + return + } + + // Check password + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) + return + } + + // Generate JWT token + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "user_id": user.ID, + "exp": time.Now().Add(time.Hour * 24).Unix(), + }) + + tokenString, err := token.SignedString(jwtSecret) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"}) + return + } + + c.JSON(http.StatusOK, gin.H{"token": tokenString}) +} \ No newline at end of file diff --git a/handlers/sheets.go b/handlers/sheets.go new file mode 100644 index 0000000..eb04aad --- /dev/null +++ b/handlers/sheets.go @@ -0,0 +1,150 @@ +package handlers + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sheetless-server/database" + "sheetless-server/models" + "strconv" + "time" + + "github.com/gin-gonic/gin" +) + +const uploadDir = "./uploads" + +func init() { + // Create uploads directory if it doesn't exist + if err := os.MkdirAll(uploadDir, 0755); err != nil { + panic("Failed to create uploads directory: " + err.Error()) + } +} + +func UploadSheet(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + // Get form data + title := c.PostForm("title") + if title == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Title is required"}) + return + } + + composer := c.PostForm("composer") + description := c.PostForm("description") + + // Get uploaded file + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "File upload failed"}) + return + } + defer file.Close() + + // Validate file type (should be PDF) + if filepath.Ext(header.Filename) != ".pdf" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Only PDF files are allowed"}) + return + } + + // Generate unique filename + filename := fmt.Sprintf("%d_%d%s", userID.(uint), time.Now().Unix(), filepath.Ext(header.Filename)) + filePath := filepath.Join(uploadDir, filename) + + // Save file + out, err := os.Create(filePath) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save file"}) + return + } + defer out.Close() + + _, err = io.Copy(out, file) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save file"}) + return + } + + // Get file size + fileInfo, err := out.Stat() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get file info"}) + return + } + + // Create database record + sheet := models.MusicSheet{ + Title: title, + Composer: composer, + Description: description, + FilePath: filePath, + FileSize: fileInfo.Size(), + UserID: userID.(uint), + } + + if err := database.DB.Create(&sheet).Error; err != nil { + // Clean up file if database insert fails + os.Remove(filePath) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save sheet metadata"}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Sheet uploaded successfully", + "sheet": sheet, + }) +} + +func ListSheets(c *gin.Context) { + var sheets []models.MusicSheet + + query := database.DB.Preload("User") + + // Filter by user if specified + if userIDStr := c.Query("user_id"); userIDStr != "" { + userID, err := strconv.ParseUint(userIDStr, 10, 32) + if err == nil { + query = query.Where("user_id = ?", uint(userID)) + } + } + + if err := query.Find(&sheets).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch sheets"}) + return + } + + c.JSON(http.StatusOK, gin.H{"sheets": sheets}) +} + +func DownloadSheet(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid sheet ID"}) + return + } + + var sheet models.MusicSheet + if err := database.DB.First(&sheet, uint(id)).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Sheet not found"}) + return + } + + // Check if file exists + if _, err := os.Stat(sheet.FilePath); os.IsNotExist(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "File not found"}) + return + } + + // Set headers for file download + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filepath.Base(sheet.FilePath))) + c.Header("Content-Type", "application/pdf") + c.File(sheet.FilePath) +} \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..8bdfa8d --- /dev/null +++ b/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "log" + "sheetless-server/database" + "sheetless-server/routes" + + "github.com/gin-gonic/gin" +) + +func main() { + database.InitDatabase() + + r := gin.Default() + + routes.SetupRoutes(r) + + log.Println("Server starting on port 8080...") + if err := r.Run(":8080"); err != nil { + log.Fatal("Failed to start server:", err) + } +} diff --git a/middleware/auth.go b/middleware/auth.go new file mode 100644 index 0000000..45b3102 --- /dev/null +++ b/middleware/auth.go @@ -0,0 +1,50 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +var jwtSecret = []byte("your-secret-key") // Should match the one in handlers/auth.go + +func AuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"}) + c.Abort() + return + } + + // Extract token from "Bearer " format + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + if tokenString == authHeader { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token format"}) + c.Abort() + return + } + + // Parse and validate token + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return jwtSecret, nil + }) + + if err != nil || !token.Valid { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) + c.Abort() + return + } + + // Extract claims + if claims, ok := token.Claims.(jwt.MapClaims); ok { + if userID, ok := claims["user_id"].(float64); ok { + c.Set("user_id", uint(userID)) + } + } + + c.Next() + } +} \ No newline at end of file diff --git a/models/sheet.go b/models/sheet.go new file mode 100644 index 0000000..a9b7936 --- /dev/null +++ b/models/sheet.go @@ -0,0 +1,21 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type MusicSheet struct { + ID uint `json:"id" gorm:"primaryKey"` + Title string `json:"title" gorm:"not null"` + Composer string `json:"composer"` + Description string `json:"description"` + FilePath string `json:"file_path" gorm:"not null"` + FileSize int64 `json:"file_size"` + UserID uint `json:"user_id"` + User User `json:"user" gorm:"foreignKey:UserID"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} \ No newline at end of file diff --git a/models/user.go b/models/user.go new file mode 100644 index 0000000..344ddec --- /dev/null +++ b/models/user.go @@ -0,0 +1,17 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type User struct { + ID uint `json:"id" gorm:"primaryKey"` + Username string `json:"username" gorm:"unique;not null"` + Email string `json:"email" gorm:"unique;not null"` + Password string `json:"-" gorm:"not null"` // Don't include in JSON responses + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} \ No newline at end of file diff --git a/routes/routes.go b/routes/routes.go new file mode 100644 index 0000000..2213b49 --- /dev/null +++ b/routes/routes.go @@ -0,0 +1,29 @@ +package routes + +import ( + "sheetless-server/handlers" + "sheetless-server/middleware" + + "github.com/gin-gonic/gin" +) + +func SetupRoutes(r *gin.Engine) { + // Public routes + auth := r.Group("/auth") + { + auth.POST("/register", handlers.Register) + auth.POST("/login", handlers.Login) + } + + // Protected routes + api := r.Group("/api") + api.Use(middleware.AuthMiddleware()) + { + sheets := api.Group("/sheets") + { + sheets.POST("/upload", handlers.UploadSheet) + sheets.GET("", handlers.ListSheets) + sheets.GET("/download/:id", handlers.DownloadSheet) + } + } +} \ No newline at end of file