Initial commit
Go CI / build (push) Failing after 2m42s

feat: create basic server to manage google oauth, account, sessions, places, attributes and ratings.
This commit is contained in:
2025-09-19 02:43:04 -07:00
commit f1909da1ad
26 changed files with 2619 additions and 0 deletions
+72
View File
@@ -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)
}
+89
View File
@@ -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)
}
+48
View File
@@ -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()
}
}
+94
View File
@@ -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)
}
+124
View File
@@ -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)
}