From 2a46aa7d79cf7f0138e895cfd064e83872ac3147 Mon Sep 17 00:00:00 2001 From: Arkaprabha Chakraborty Date: Fri, 13 Feb 2026 00:01:00 +0530 Subject: [PATCH] feat: version 2.1 --- README.md | 6 +- backend/auth.go | 196 +++++++++++++++++- backend/handlers.go | 15 ++ backend/main.go | 27 ++- frontend/index.html | 12 +- frontend/src/App.tsx | 2 + frontend/src/components/Navbar.tsx | 10 +- frontend/src/context/AuthContext.tsx | 38 ++-- frontend/src/lib/api.ts | 9 +- frontend/src/pages/Home.tsx | 2 +- frontend/src/pages/Links.tsx | 2 +- frontend/src/pages/Redirect.tsx | 4 +- frontend/src/pages/User.tsx | 288 +++++++++++++++++++++++++++ 13 files changed, 548 insertions(+), 63 deletions(-) create mode 100644 frontend/src/pages/User.tsx 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 @@ - Reduce - URL Shortener - + Reduce - Those Pesky Long URLs + - - + + - - + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 885b4dd..bbd4b6c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import Redirect from './pages/Redirect'; import Login from './pages/Login'; import Register from './pages/Register'; import Links from './pages/Links'; +import User from './pages/User'; function App() { return ( @@ -17,6 +18,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index b13fb45..10e7089 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -1,6 +1,6 @@ import { Link } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; -import { MdPerson, MdLogout, MdLink } from 'react-icons/md'; +import { MdPerson, MdLogout, MdLink, MdSettings } from 'react-icons/md'; export default function Navbar() { const { user, logout } = useAuth(); @@ -21,6 +21,14 @@ export default function Navbar() { Links + + + Account + {user.username} diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 3e58ab7..92309b9 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -8,7 +8,6 @@ interface User { interface AuthContextType { user: User | null; - token: string | null; login: (username: string, password: string) => Promise; register: (username: string, password: string) => Promise; logout: () => void; @@ -19,47 +18,38 @@ const AuthContext = createContext(undefined); export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); - const [token, setToken] = useState(localStorage.getItem('token')); const [isLoading, setIsLoading] = useState(true); useEffect(() => { - if (token) { - api - .get('/auth/me') - .then((res) => setUser(res.data)) - .catch(() => { - localStorage.removeItem('token'); - setToken(null); - setUser(null); - }) - .finally(() => setIsLoading(false)); - } else { - setIsLoading(false); - } - }, [token]); + // Try to authenticate with existing cookie + api + .get('/auth/me') + .then((res) => setUser(res.data)) + .catch(() => setUser(null)) + .finally(() => setIsLoading(false)); + }, []); const login = async (username: string, password: string) => { const res = await api.post('/auth/login', { username, password }); - localStorage.setItem('token', res.data.token); - setToken(res.data.token); setUser(res.data.user); }; const register = async (username: string, password: string) => { const res = await api.post('/auth/register', { username, password }); - localStorage.setItem('token', res.data.token); - setToken(res.data.token); setUser(res.data.user); }; - const logout = () => { - localStorage.removeItem('token'); - setToken(null); + const logout = async () => { + try { + await api.post('/auth/logout'); + } catch { + // Ignore errors during logout + } setUser(null); }; return ( - + {children} ); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 96f202b..076059d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -2,14 +2,7 @@ import axios from 'axios'; const api = axios.create({ baseURL: import.meta.env.VITE_BACKEND_URL || 'http://localhost:8080', -}); - -api.interceptors.request.use((config) => { - const token = localStorage.getItem('token'); - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; + withCredentials: true, // Send cookies with requests }); export default api; diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index a381172..b8aa04c 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -55,7 +55,7 @@ export default function Home() { payload.requires_auth = true; } - const response = await api.post('/reduce/shorten', payload); + const response = await api.post('/shorten', payload); setShortUrl(response.data.surl); setPrevLongUrl(longUrl); diff --git a/frontend/src/pages/Links.tsx b/frontend/src/pages/Links.tsx index 489f982..2ce3c84 100644 --- a/frontend/src/pages/Links.tsx +++ b/frontend/src/pages/Links.tsx @@ -243,7 +243,7 @@ function LinkModal({ mode, link, onClose, onSaved }: LinkModalProps) { const baseURL = window.location.hostname === 'r.webark.in' ? 'https://r.webark.in' : window.location.origin; - await api.post('/reduce/shorten', { + await api.post('/shorten', { lurl: longUrl, base_url: baseURL, code: code || undefined, diff --git a/frontend/src/pages/Redirect.tsx b/frontend/src/pages/Redirect.tsx index 53ca4d2..781b94b 100644 --- a/frontend/src/pages/Redirect.tsx +++ b/frontend/src/pages/Redirect.tsx @@ -17,7 +17,7 @@ export default function Redirect() { if (!code) return; try { - const response = await api.get(`/reduce/${code}`); + const response = await api.get(`/${code}`); if (response.data.requires_auth) { setNeedsAuth(true); } else if (response.data.lurl) { @@ -39,7 +39,7 @@ export default function Redirect() { setVerifying(true); try { - const res = await api.post(`/reduce/${code}/verify`, { username, password }); + const res = await api.post(`/${code}/verify`, { username, password }); if (res.data.lurl) { window.location.replace(res.data.lurl); } else { diff --git a/frontend/src/pages/User.tsx b/frontend/src/pages/User.tsx new file mode 100644 index 0000000..35dc518 --- /dev/null +++ b/frontend/src/pages/User.tsx @@ -0,0 +1,288 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; +import api from '../lib/api'; +import { MdPerson, MdLock, MdDelete, MdSave, MdBarChart } from 'react-icons/md'; + +interface UserStats { + link_count: number; + total_clicks: number; +} + +export default function User() { + const { user, isLoading, logout } = useAuth(); + const navigate = useNavigate(); + + const [stats, setStats] = useState({ link_count: 0, total_clicks: 0 }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + // Username update + const [newUsername, setNewUsername] = useState(''); + const [updatingUsername, setUpdatingUsername] = useState(false); + + // Password update + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [updatingPassword, setUpdatingPassword] = useState(false); + + // Delete account + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + useEffect(() => { + if (!isLoading && !user) { + navigate('/login'); + } + }, [user, isLoading, navigate]); + + useEffect(() => { + if (user) { + setNewUsername(user.username); + fetchStats(); + } + }, [user]); + + const fetchStats = async () => { + try { + const res = await api.get('/user/stats'); + setStats(res.data); + } catch { + setError('Failed to load stats'); + } finally { + setLoading(false); + } + }; + + const handleUpdateUsername = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setSuccess(''); + setUpdatingUsername(true); + + try { + const res = await api.put('/user/username', { username: newUsername }); + setSuccess('Username updated successfully'); + // Update the user in context would require refetching /auth/me + window.location.reload(); // Simple way to update context + } catch (err: any) { + setError(err.response?.data?.message || 'Failed to update username'); + } finally { + setUpdatingUsername(false); + } + }; + + const handleUpdatePassword = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setSuccess(''); + + if (newPassword !== confirmPassword) { + setError('Passwords do not match'); + return; + } + + setUpdatingPassword(true); + + try { + await api.put('/user/password', { + current_password: currentPassword, + new_password: newPassword, + }); + setSuccess('Password updated successfully'); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + } catch (err: any) { + setError(err.response?.data?.message || 'Failed to update password'); + } finally { + setUpdatingPassword(false); + } + }; + + const handleDeleteAccount = async () => { + try { + await api.delete('/user/account'); + logout(); + navigate('/'); + } catch (err: any) { + setError(err.response?.data?.message || 'Failed to delete account'); + setShowDeleteConfirm(false); + } + }; + + if (isLoading || loading) { + return ( +
+
Loading...
+
+ ); + } + + return ( +
+
+ {/* Header */} +

+ Account Settings +

+ + {/* Messages */} + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + + {/* Stats */} +
+
+ +

Statistics

+
+
+
+
{stats.link_count}
+
Total Links
+
+
+
{stats.total_clicks}
+
Total Clicks
+
+
+
+ + {/* Update Username */} +
+
+ +

Username

+
+
+
+ + setNewUsername(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-800 px-4 py-2 text-zinc-100 text-sm focus:outline-none focus:border-zinc-600" + required + minLength={3} + maxLength={32} + /> +
+ +
+
+ + {/* Update Password */} +
+
+ +

Password

+
+
+
+ + setCurrentPassword(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-800 px-4 py-2 text-zinc-100 text-sm focus:outline-none focus:border-zinc-600" + required + /> +
+
+ + setNewPassword(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-800 px-4 py-2 text-zinc-100 text-sm focus:outline-none focus:border-zinc-600" + required + minLength={6} + /> +
+
+ + setConfirmPassword(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-800 px-4 py-2 text-zinc-100 text-sm focus:outline-none focus:border-zinc-600" + required + minLength={6} + /> +
+ +
+
+ + {/* Delete Account */} +
+
+ +

Danger Zone

+
+

+ Deleting your account will permanently remove all your data, including all your shortened links. This action cannot be undone. +

+ {!showDeleteConfirm ? ( + + ) : ( +
+

+ Are you absolutely sure? +

+
+ + +
+
+ )} +
+
+
+ ); +}