feat: create basic server to manage google oauth, account, sessions, places, attributes and ratings.
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.pengzhan.dev/noteplace-server/internal/models"
|
||||
"git.pengzhan.dev/noteplace-server/internal/store"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AttributesHandler handles HTTP requests for attributes.
|
||||
type AttributesHandler struct {
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
// HandleCreateAttribute creates a new attribute.
|
||||
func (h *AttributesHandler) HandleCreateAttribute(c *gin.Context) {
|
||||
var newAttr models.Attribute
|
||||
if err := c.ShouldBindJSON(&newAttr); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
// --- Validation ---
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: User not found in context"})
|
||||
return
|
||||
}
|
||||
|
||||
if user.(models.User).Role > models.TRUSTED {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: User not allowed to perform this action"})
|
||||
return
|
||||
}
|
||||
|
||||
if newAttr.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Name is a required field"})
|
||||
return
|
||||
}
|
||||
|
||||
switch newAttr.Scope {
|
||||
case models.CATEGORY:
|
||||
if newAttr.OwnerID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "OwnerID (PlaceCategory) is required for CATEGORY attributes"})
|
||||
return
|
||||
}
|
||||
case models.PLACE:
|
||||
if newAttr.OwnerID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "OwnerID (PlaceID) is required for PLACE attributes"})
|
||||
return
|
||||
}
|
||||
if _, found := h.Store.GetPlaceByID(newAttr.OwnerID); !found {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "OwnerID (PlaceID) does not refer to a valid place"})
|
||||
return
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Scope must be either CATEGORY or PLACE"})
|
||||
return
|
||||
}
|
||||
|
||||
newAttr.ID = "attr-" + uuid.New().String()
|
||||
|
||||
// UserID for attribute creation is not specified in DESIGN.md, assuming it's not directly linked to the creator for now.
|
||||
|
||||
if err := h.Store.CreateAttribute(newAttr); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save attribute"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, newAttr)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.pengzhan.dev/noteplace-server/internal/auth"
|
||||
"git.pengzhan.dev/noteplace-server/internal/models"
|
||||
"git.pengzhan.dev/noteplace-server/internal/store"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AuthHandler handles the authentication API endpoints.
|
||||
type AuthHandler struct {
|
||||
Store *store.Store
|
||||
Authenticator *auth.Authenticator
|
||||
}
|
||||
|
||||
// HandleCliLogin provides the user with the URL to start the auth process.
|
||||
func (h *AuthHandler) HandleCliLogin(c *gin.Context) {
|
||||
authURL := h.Authenticator.GetAuthURL()
|
||||
|
||||
response := gin.H{
|
||||
"message": `Please open the following URL in your browser to authorize the application.\nAfter authorizing, Google will provide a code. Paste it here to complete the login.\nExample: curl -X POST -d '{\"code\":\"YOUR_CODE_HERE\"}' http://localhost:3001/api/auth/google/cli/callback`,
|
||||
"url": authURL,
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// HandleCliCallback exchanges the code for a token and creates a session.
|
||||
func (h *AuthHandler) HandleCliCallback(c *gin.Context) {
|
||||
var reqBody struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&reqBody); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := h.Authenticator.ExchangeCodeForToken(reqBody.Code)
|
||||
if err != nil {
|
||||
log.Printf("Failed to exchange code for token: %v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Failed to validate code"})
|
||||
return
|
||||
}
|
||||
|
||||
userInfo, err := h.Authenticator.GetUserInfo(token)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get user info: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user info from Google"})
|
||||
return
|
||||
}
|
||||
|
||||
// --- User and Session Logic ---
|
||||
user, found := h.Store.GetUserByGoogleID(userInfo.GoogleID)
|
||||
if !found {
|
||||
userInfo.ID = "user-" + uuid.New().String()
|
||||
userInfo.CreatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
if err := h.Store.CreateUser(userInfo); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
||||
return
|
||||
}
|
||||
user = userInfo
|
||||
}
|
||||
|
||||
// Create a new session and save it to the store
|
||||
sessionID := "session-" + uuid.New().String()
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
newSession := models.Session{
|
||||
ID: sessionID,
|
||||
UserID: user.ID,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := h.Store.CreateSession(newSession); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Login successful!", "session_token": newSession.ID})
|
||||
}
|
||||
|
||||
// HandleGetUser returns the current user based on the session token.
|
||||
func (h *AuthHandler) HandleGetUser(c *gin.Context) {
|
||||
user := c.MustGet("user").(models.User)
|
||||
c.JSON(http.StatusOK, user)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.pengzhan.dev/noteplace-server/internal/store"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AuthMiddleware is a Gin middleware for authentication.
|
||||
func AuthMiddleware(s *store.Store) 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
|
||||
}
|
||||
|
||||
sessionToken := authHeader
|
||||
// Assuming the token is directly the session token, not "Bearer <token>"
|
||||
// If it's "Bearer <token>", you'd need to parse it:
|
||||
// parts := strings.Split(authHeader, " ")
|
||||
// if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
// c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization header format"})
|
||||
// c.Abort()
|
||||
// return
|
||||
// }
|
||||
// sessionToken = parts[1]
|
||||
|
||||
session, found := s.GetSessionBySessionID(sessionToken)
|
||||
if !found {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired session token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
user, found := s.GetUserByID(session.UserID)
|
||||
if !found {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user", user)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.pengzhan.dev/noteplace-server/internal/models"
|
||||
"git.pengzhan.dev/noteplace-server/internal/store"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// PlacesHandler handles HTTP requests for places.
|
||||
type PlacesHandler struct {
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
// HandleGetPlaces returns a list of all places.
|
||||
func (h *PlacesHandler) HandleGetPlaces(c *gin.Context) {
|
||||
h.Store.Mu.RLock()
|
||||
defer h.Store.Mu.RUnlock()
|
||||
places := h.Store.Data.Places
|
||||
c.JSON(http.StatusOK, places)
|
||||
}
|
||||
|
||||
// HandleCreatePlace creates a new place.
|
||||
func (h *PlacesHandler) HandleCreatePlace(c *gin.Context) {
|
||||
var newPlace models.Place
|
||||
if err := c.ShouldBindJSON(&newPlace); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if newPlace.Name == "" || newPlace.Category == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Name and Category are required fields"})
|
||||
return
|
||||
}
|
||||
|
||||
newPlace.ID = "place-" + uuid.New().String()
|
||||
newPlace.CreatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
// Extract UserID from context after authentication middleware
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: User not found in context"})
|
||||
return
|
||||
}
|
||||
|
||||
if user.(models.User).Role > models.TRUSTED {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: User not allowed to perform this action"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.Store.CreatePlace(newPlace); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save place"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, newPlace)
|
||||
}
|
||||
|
||||
// HandleGetPlaceByID returns a single place by its ID.
|
||||
func (h *PlacesHandler) HandleGetPlaceByID(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
place, found := h.Store.GetPlaceByID(id)
|
||||
if !found {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Place not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, place)
|
||||
}
|
||||
|
||||
// HandleGetPlaceRatings returns all ratings for a specific place.
|
||||
func (h *PlacesHandler) HandleGetPlaceRatings(c *gin.Context) {
|
||||
placeID := c.Param("id")
|
||||
if _, found := h.Store.GetPlaceByID(placeID); !found {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Place not found"})
|
||||
return
|
||||
}
|
||||
ratings := h.Store.GetRatingsByPlaceID(placeID)
|
||||
c.JSON(http.StatusOK, ratings)
|
||||
}
|
||||
|
||||
// HandleGetPlaceAttributes returns all relevant attributes for a specific place.
|
||||
func (h *PlacesHandler) HandleGetPlaceAttributes(c *gin.Context) {
|
||||
placeID := c.Param("id")
|
||||
place, found := h.Store.GetPlaceByID(placeID)
|
||||
if !found {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Place not found"})
|
||||
return
|
||||
}
|
||||
attributes := h.Store.GetAttributesByPlace(place)
|
||||
c.JSON(http.StatusOK, attributes)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.pengzhan.dev/noteplace-server/internal/models"
|
||||
"git.pengzhan.dev/noteplace-server/internal/store"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// RatingsHandler handles HTTP requests for ratings.
|
||||
type RatingsHandler struct {
|
||||
Store *store.Store
|
||||
}
|
||||
|
||||
// HandleCreateRating creates a new rating.
|
||||
func (h *RatingsHandler) HandleCreateRating(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: User not found in context"})
|
||||
return
|
||||
}
|
||||
|
||||
var newRating models.Rating
|
||||
if err := c.ShouldBindJSON(&newRating); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if newRating.PlaceID == "" || newRating.AttributeID == "" || newRating.Score < 1 || newRating.Score > 10 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input: PlaceID, AttributeID, and Score (1-10) are required"})
|
||||
return
|
||||
}
|
||||
|
||||
newRating.ID = "rating-" + uuid.New().String()
|
||||
newRating.CreatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
newRating.UserID = user.(models.User).ID
|
||||
|
||||
if err := h.Store.CreateRating(newRating); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save rating"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, newRating)
|
||||
}
|
||||
|
||||
// HandleUpdateRating updates an existing rating.
|
||||
func (h *RatingsHandler) HandleUpdateRating(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: User not found in context"})
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
var reqBody struct {
|
||||
Score int `json:"score"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&reqBody); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
if reqBody.Score < 1 || reqBody.Score > 10 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Score must be between 1 and 10"})
|
||||
return
|
||||
}
|
||||
|
||||
rating, found := h.Store.GetRatingByID(id)
|
||||
if !found {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Rating not found"})
|
||||
return
|
||||
}
|
||||
if rating.UserID != user.(models.User).ID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden: You can only update your own ratings"})
|
||||
return
|
||||
}
|
||||
|
||||
updatedRating, found := h.Store.UpdateRating(id, reqBody.Score, reqBody.Comment)
|
||||
if !found {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update rating"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, updatedRating)
|
||||
}
|
||||
|
||||
// HandleDeleteRating deletes a rating.
|
||||
func (h *RatingsHandler) HandleDeleteRating(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: User not found in context"})
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
rating, found := h.Store.GetRatingByID(id)
|
||||
if !found {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Rating not found"})
|
||||
return
|
||||
}
|
||||
if rating.UserID != user.(models.User).ID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Forbidden: You can only delete your own ratings"})
|
||||
return
|
||||
}
|
||||
|
||||
if !h.Store.DeleteRating(id) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete rating"})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// HandleGetMyRatings gets all ratings for the current user.
|
||||
func (h *RatingsHandler) HandleGetMyRatings(c *gin.Context) {
|
||||
user, exists := c.Get("user")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized: User not found in context"})
|
||||
return
|
||||
}
|
||||
|
||||
ratings := h.Store.GetRatingsByUserID(user.(models.User).ID)
|
||||
c.JSON(http.StatusOK, ratings)
|
||||
}
|
||||
Reference in New Issue
Block a user