diff --git a/README.md b/README.md index a9a2056..396d2a7 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,9 @@ A minimal URL shortener with user accounts, custom short codes, and password-pro | Method | Path | Auth | Description | | ------ | ---------------------- | -------- | ------------------------------------- | -| POST | `/reduce/shorten` | Optional | Create short link | -| GET | `/reduce/:code` | — | Resolve short code | -| POST | `/reduce/:code/verify` | — | Verify credentials for protected link | +| POST | `/shorten` | Optional | Create short link | +| GET | `/:code` | — | Resolve short code | +| POST | `/:code/verify` | — | Verify credentials for protected link | ### Links (dashboard) diff --git a/backend/auth.go b/backend/auth.go index ddf173b..765d2dd 100644 --- a/backend/auth.go +++ b/backend/auth.go @@ -56,11 +56,21 @@ func parseToken(tokenStr string) (*JWTClaims, error) { // JWTMiddleware requires a valid JWT token func JWTMiddleware(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { - auth := c.Request().Header.Get("Authorization") - if auth == "" || !strings.HasPrefix(auth, "Bearer ") { - return echo.NewHTTPError(http.StatusUnauthorized, "Missing or invalid token") + // Try to get token from cookie first + cookie, err := c.Cookie("token") + var tokenStr string + if err == nil && cookie != nil { + tokenStr = cookie.Value + } else { + // Fallback to Authorization header for backward compatibility + auth := c.Request().Header.Get("Authorization") + if auth == "" || !strings.HasPrefix(auth, "Bearer ") { + return echo.NewHTTPError(http.StatusUnauthorized, "Missing or invalid token") + } + tokenStr = strings.TrimPrefix(auth, "Bearer ") } - claims, err := parseToken(strings.TrimPrefix(auth, "Bearer ")) + + claims, err := parseToken(tokenStr) if err != nil { return echo.NewHTTPError(http.StatusUnauthorized, "Invalid or expired token") } @@ -73,9 +83,21 @@ func JWTMiddleware(next echo.HandlerFunc) echo.HandlerFunc { // OptionalJWTMiddleware extracts user if token present, doesn't require it func OptionalJWTMiddleware(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { - auth := c.Request().Header.Get("Authorization") - if auth != "" && strings.HasPrefix(auth, "Bearer ") { - if claims, err := parseToken(strings.TrimPrefix(auth, "Bearer ")); err == nil { + // Try to get token from cookie first + cookie, err := c.Cookie("token") + var tokenStr string + if err == nil && cookie != nil { + tokenStr = cookie.Value + } else { + // Fallback to Authorization header + auth := c.Request().Header.Get("Authorization") + if auth != "" && strings.HasPrefix(auth, "Bearer ") { + tokenStr = strings.TrimPrefix(auth, "Bearer ") + } + } + + if tokenStr != "" { + if claims, err := parseToken(tokenStr); err == nil { c.Set("user_id", claims.UserID) c.Set("username", claims.Username) } @@ -122,9 +144,20 @@ func register(c echo.Context) error { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate token") } + // Set httpOnly cookie + cookie := &http.Cookie{ + Name: "token", + Value: token, + Path: "/", + HttpOnly: true, + Secure: os.Getenv("ENV") == "production", // Only secure in production + SameSite: http.SameSiteLaxMode, + MaxAge: 72 * 3600, // 72 hours + } + c.SetCookie(cookie) + return c.JSON(http.StatusCreated, map[string]interface{}{ - "token": token, - "user": map[string]interface{}{"id": user.ID, "username": user.Username}, + "user": map[string]interface{}{"id": user.ID, "username": user.Username}, }) } @@ -152,9 +185,36 @@ func login(c echo.Context) error { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate token") } + // Set httpOnly cookie + cookie := &http.Cookie{ + Name: "token", + Value: token, + Path: "/", + HttpOnly: true, + Secure: os.Getenv("ENV") == "production", // Only secure in production + SameSite: http.SameSiteLaxMode, + MaxAge: 72 * 3600, // 72 hours + } + c.SetCookie(cookie) + return c.JSON(http.StatusOK, map[string]interface{}{ - "token": token, - "user": map[string]interface{}{"id": user.ID, "username": user.Username}, + "user": map[string]interface{}{"id": user.ID, "username": user.Username}, + }) +} + +func logout(c echo.Context) error { + // Clear the cookie + cookie := &http.Cookie{ + Name: "token", + Value: "", + Path: "/", + HttpOnly: true, + MaxAge: -1, // Delete cookie + } + c.SetCookie(cookie) + + return c.JSON(http.StatusOK, map[string]interface{}{ + "message": "Logged out successfully", }) } @@ -169,3 +229,117 @@ func getMe(c echo.Context) error { "username": user.Username, }) } + +func updateUsername(c echo.Context) error { + uid := c.Get("user_id").(uint) + type Req struct { + Username string `json:"username"` + } + r := new(Req) + if err := c.Bind(r); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid request") + } + + r.Username = strings.TrimSpace(r.Username) + if len(r.Username) < 3 || len(r.Username) > 32 { + return echo.NewHTTPError(http.StatusBadRequest, "Username must be 3–32 characters") + } + + // Check if username is already taken by another user + var existing User + if db.Where("username = ? AND id != ?", r.Username, uid).First(&existing).Error == nil { + return echo.NewHTTPError(http.StatusConflict, "Username already taken") + } + + var user User + if err := db.First(&user, uid).Error; err != nil { + return echo.NewHTTPError(http.StatusNotFound, "User not found") + } + + user.Username = r.Username + if err := db.Save(&user).Error; err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to update username") + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "id": user.ID, + "username": user.Username, + }) +} + +func updatePassword(c echo.Context) error { + uid := c.Get("user_id").(uint) + type Req struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` + } + r := new(Req) + if err := c.Bind(r); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid request") + } + + if len(r.NewPassword) < 6 { + return echo.NewHTTPError(http.StatusBadRequest, "Password must be at least 6 characters") + } + + var user User + if err := db.First(&user, uid).Error; err != nil { + return echo.NewHTTPError(http.StatusNotFound, "User not found") + } + + // Verify current password + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(r.CurrentPassword)); err != nil { + return echo.NewHTTPError(http.StatusUnauthorized, "Current password is incorrect") + } + + // Hash new password + hash, err := bcrypt.GenerateFromPassword([]byte(r.NewPassword), bcrypt.DefaultCost) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to process password") + } + + user.Password = string(hash) + if err := db.Save(&user).Error; err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to update password") + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "message": "Password updated successfully", + }) +} + +func deleteAccount(c echo.Context) error { + uid := c.Get("user_id").(uint) + + var user User + if err := db.First(&user, uid).Error; err != nil { + return echo.NewHTTPError(http.StatusNotFound, "User not found") + } + + // Delete all links owned by this user + db.Where("user_id = ?", uid).Delete(&Link{}) + + // Delete user + if err := db.Delete(&user).Error; err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete account") + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "message": "Account deleted successfully", + }) +} + +func getUserStats(c echo.Context) error { + uid := c.Get("user_id").(uint) + + var linkCount int + var totalClicks int + + db.Model(&Link{}).Where("user_id = ?", uid).Count(&linkCount) + db.Model(&Link{}).Where("user_id = ?", uid).Select("COALESCE(SUM(click_count), 0)").Row().Scan(&totalClicks) + + return c.JSON(http.StatusOK, map[string]interface{}{ + "link_count": linkCount, + "total_clicks": totalClicks, + }) +} diff --git a/backend/handlers.go b/backend/handlers.go index 0cbb121..7f6d962 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -148,6 +148,21 @@ func fetchLURL(c echo.Context) error { } if link.RequiresAuth { + // Check if user is authenticated and authorized + if username, ok := c.Get("username").(string); ok { + // User is logged in, check if they match the access credentials + if username == link.AccessUsername { + // Auto-authorize logged-in user + db.Model(&link).UpdateColumn("click_count", gorm.Expr("click_count + 1")) + return c.JSON(http.StatusOK, map[string]interface{}{ + "lurl": link.LongURL, + "requires_auth": false, + "auto_authorized": true, + }) + } + } + + // User not logged in or doesn't match - require manual auth return c.JSON(http.StatusOK, map[string]interface{}{ "requires_auth": true, "code": link.Code, diff --git a/backend/main.go b/backend/main.go index bdc9c16..a852b7c 100644 --- a/backend/main.go +++ b/backend/main.go @@ -15,13 +15,20 @@ func main() { e := echo.New() + // Frontend URL for CORS + frontendURL := os.Getenv("BASE_URL") + if frontendURL == "" { + frontendURL = "http://localhost:5173" + } + // Middleware e.Use(middleware.Logger()) e.Use(middleware.Recover()) e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ - AllowOrigins: []string{"*"}, - AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}, - AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization}, + AllowOrigins: []string{frontendURL}, + AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}, + AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization}, + AllowCredentials: true, })) // Health @@ -32,12 +39,20 @@ func main() { // Auth e.POST("/auth/register", register) e.POST("/auth/login", login) + e.POST("/auth/logout", logout) e.GET("/auth/me", getMe, JWTMiddleware) + // User account management + user := e.Group("/user", JWTMiddleware) + user.GET("/stats", getUserStats) + user.PUT("/username", updateUsername) + user.PUT("/password", updatePassword) + user.DELETE("/account", deleteAccount) + // Public link routes (optional auth for shorten) - e.POST("/reduce/shorten", shortenURL, OptionalJWTMiddleware) - e.GET("/reduce/:code", fetchLURL) - e.POST("/reduce/:code/verify", verifyAndRedirect) + e.POST("/shorten", shortenURL, OptionalJWTMiddleware) + e.GET("/:code", fetchLURL, OptionalJWTMiddleware) + e.POST("/:code/verify", verifyAndRedirect) // Authenticated link management links := e.Group("/links", JWTMiddleware) diff --git a/frontend/index.html b/frontend/index.html index 5d8fe52..40256f1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,19 +4,19 @@ -
+ Deleting your account will permanently remove all your data, including all your shortened links. This action cannot be undone. +
+ {!showDeleteConfirm ? ( + + ) : ( ++ Are you absolutely sure? +
+