From 005838045a124ca2a02a496fd039a454de9a7a6d Mon Sep 17 00:00:00 2001 From: Arkaprabha Chakraborty Date: Thu, 12 Feb 2026 05:51:56 +0530 Subject: [PATCH] feat: version 2 --- README.md | 118 +++++--- backend.deploy.yml | 14 - backend/.gitignore | 8 +- backend/Dockerfile | 23 -- backend/auth.go | 171 +++++++++++ backend/go.mod | 5 +- backend/go.sum | 2 + backend/handlers.go | 325 +++++++++++++++++++++ backend/main.go | 116 ++------ backend/store.go | 34 ++- compose.yml | 28 -- frontend/src/App.tsx | 23 +- frontend/src/components/Navbar.tsx | 56 ++++ frontend/src/context/AuthContext.tsx | 72 +++++ frontend/src/lib/api.ts | 15 + frontend/src/pages/Home.tsx | 249 ++++++++++------ frontend/src/pages/Links.tsx | 418 +++++++++++++++++++++++++++ frontend/src/pages/Login.tsx | 79 +++++ frontend/src/pages/Redirect.tsx | 125 ++++++-- frontend/src/pages/Register.tsx | 98 +++++++ 20 files changed, 1645 insertions(+), 334 deletions(-) delete mode 100644 backend.deploy.yml delete mode 100644 backend/Dockerfile create mode 100644 backend/auth.go create mode 100644 backend/handlers.go delete mode 100644 compose.yml create mode 100644 frontend/src/components/Navbar.tsx create mode 100644 frontend/src/context/AuthContext.tsx create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/pages/Links.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/Register.tsx diff --git a/README.md b/README.md index a402297..a9a2056 100644 --- a/README.md +++ b/README.md @@ -2,77 +2,99 @@ # Reduce -Reduce is a URL shortening service that allows users to easily shorten long URLs for easy access and sharing. This repository contains both the backend and frontend code for the Reduce application. +A minimal URL shortener with user accounts, custom short codes, and password-protected links. ## Features -- URL shortening: Convert long URLs into short, easy-to-share URLs. -- Automatic URL retrieval: Easily retrieve the original long URL using the short URL. -- Unique ID generation: Each shortened URL is given a unique, randomly generated ID. -- Responsive UI: User-friendly interface with React, Tailwind CSS, and MUI components. +- **Shorten URLs** — works anonymously or logged-in +- **User accounts** — register / login with username & password (JWT) +- **Custom short codes** — choose your own slug (logged-in users) +- **Protected links** — require authentication before redirect (uses account credentials or custom) +- **Dashboard** — view, edit, and delete your links +- **Click tracking** — see how many times each link was visited +- **QR codes** — generated for every shortened URL -## Technologies Used +## Production -- **Backend**: Go, Echo, GORM -- **Frontend**: React, Axios, Tailwind CSS, MUI -- **Database**: PostgreSQL -- **Containerization**: Docker, Docker Compose +- **Frontend & Short links**: https://r.webark.in +- **API**: https://api.r.webark.in -## Getting Started +## Tech Stack -### Prerequisites +| Layer | Stack | +| -------- | ------------------------------ | +| Backend | Go · Echo · GORM · SQLite | +| Frontend | React · TypeScript · Tailwind | +| Auth | JWT (72 h expiry) · bcrypt | -- Docker and Docker Compose installed -- Node.js and npm installed +## API -### Backend Setup +### Auth -1. Ensure the environment variables are set: +| Method | Path | Auth | Description | +| ------ | ----------------- | ---- | ----------------- | +| POST | `/auth/register` | — | Create account | +| POST | `/auth/login` | — | Get JWT token | +| GET | `/auth/me` | JWT | Current user info | - ``` - DB_HOST=host - DB_PORT=port - DB_USER=user - DB_NAME=name - DB_PASSWORD=password - BASE_URL=url - ``` +### Links (public) -2. Build and run the backend using Docker Compose: - ```sh - docker-compose up --build - ``` +| 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 | -### Frontend Setup +### Links (dashboard) -1. Navigate to the frontend directory: +| Method | Path | Auth | Description | +| ------ | -------------- | ---- | --------------- | +| GET | `/links` | JWT | List your links | +| PUT | `/links/:id` | JWT | Update a link | +| DELETE | `/links/:id` | JWT | Delete a link | - ```sh - cd frontend - ``` +## Quick Start -2. Create a `.env.local` file with the following variable: +### Backend - ``` - NEXT_PUBLIC_BACKEND_URL=http://localhost:8080 - ``` +```bash +cd backend +# Edit .env and set a secure JWT_SECRET: +# openssl rand -base64 32 +go run . +``` -3. Install the dependencies and run the frontend: - ```sh - npm install - npm run dev - ``` +### Frontend -## Usage +```bash +cd frontend +npm install +npm run dev +``` -1. Open your browser and navigate to `http://localhost:3000`. -2. Enter the long URL you want to shorten and click the "Reduce" button. -3. Copy the shortened URL and share it as needed. +### Docker + +```bash +JWT_SECRET=$(openssl rand -base64 32) docker compose up --build +``` + +## Database + +SQLite with two tables: + +- **users** — id, username, password (bcrypt), timestamps +- **links** — id, user_id (nullable), code (unique), long_url, is_custom, requires_auth, access_username, access_password (bcrypt), click_count, timestamps + +### Protected Links + +When a logged-in user creates a protected link, they can choose: +- **Use account credentials** — visitors must enter the owner's username/password +- **Custom credentials** — set specific username/password for this link only ## Contributing -Thank you for considering contributing to Reduce! We welcome your contributions. Whether it's fixing bugs, adding features, or improving documentation, your help is appreciated. Feel free to report issues or submit pull requests. +Contributions welcome — feel free to open issues or submit pull requests. ## License -This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for more details. +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. diff --git a/backend.deploy.yml b/backend.deploy.yml deleted file mode 100644 index 20407ed..0000000 --- a/backend.deploy.yml +++ /dev/null @@ -1,14 +0,0 @@ -services: - backend: - image: reduce:latest - container_name: reduce-backend - restart: unless-stopped - ports: - - "8081:8080" - environment: - DB_PATH: /app/data/reduce.db - BASE_URL: ${BASE_URL} - volumes: - - reduce:/app/data -volumes: - reduce: \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore index 812b73c..3dbcb26 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -7,5 +7,9 @@ pkg/ *.test *.prof .env -# Local database (if applicable) -reduce.db + +# Database +reduce.db* + +# Binary +reduce \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100644 index 392a17b..0000000 --- a/backend/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -FROM golang:1.21-alpine AS builder - -RUN apk add --no-cache gcc musl-dev - -WORKDIR /app - -COPY go.mod go.sum ./ -RUN go mod download - -COPY . . - -RUN CGO_ENABLED=1 \ - CGO_CFLAGS="-D_FILE_OFFSET_BITS=64 -D_LARGEFILE64_SOURCE" \ - CGO_LDFLAGS="-lm" \ - go build -ldflags="-s -w" . - -FROM alpine:latest - -COPY --from=builder /app/reduce /reduce - -EXPOSE 8080 - -CMD ["/reduce"] diff --git a/backend/auth.go b/backend/auth.go new file mode 100644 index 0000000..ddf173b --- /dev/null +++ b/backend/auth.go @@ -0,0 +1,171 @@ +package main + +import ( + "net/http" + "os" + "strings" + "time" + + "github.com/dgrijalva/jwt-go" + "github.com/labstack/echo" + "golang.org/x/crypto/bcrypt" +) + +// JWTClaims holds the JWT token claims +type JWTClaims struct { + UserID uint `json:"user_id"` + Username string `json:"username"` + jwt.StandardClaims +} + +func getJWTSecret() []byte { + secret := os.Getenv("JWT_SECRET") + if secret == "" { + secret = "default-dev-secret-change-in-production" + } + return []byte(secret) +} + +func generateToken(user *User) (string, error) { + claims := &JWTClaims{ + UserID: user.ID, + Username: user.Username, + StandardClaims: jwt.StandardClaims{ + ExpiresAt: time.Now().Add(72 * time.Hour).Unix(), + IssuedAt: time.Now().Unix(), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(getJWTSecret()) +} + +func parseToken(tokenStr string) (*JWTClaims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &JWTClaims{}, func(t *jwt.Token) (interface{}, error) { + return getJWTSecret(), nil + }) + if err != nil { + return nil, err + } + claims, ok := token.Claims.(*JWTClaims) + if !ok || !token.Valid { + return nil, jwt.ErrSignatureInvalid + } + return claims, nil +} + +// 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") + } + claims, err := parseToken(strings.TrimPrefix(auth, "Bearer ")) + if err != nil { + return echo.NewHTTPError(http.StatusUnauthorized, "Invalid or expired token") + } + c.Set("user_id", claims.UserID) + c.Set("username", claims.Username) + return next(c) + } +} + +// 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 { + c.Set("user_id", claims.UserID) + c.Set("username", claims.Username) + } + } + return next(c) + } +} + +func register(c echo.Context) error { + type Req struct { + Username string `json:"username"` + Password string `json:"password"` + } + 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") + } + if len(r.Password) < 6 { + return echo.NewHTTPError(http.StatusBadRequest, "Password must be at least 6 characters") + } + + var existing User + if db.Where("username = ?", r.Username).First(&existing).Error == nil { + return echo.NewHTTPError(http.StatusConflict, "Username already taken") + } + + hash, err := bcrypt.GenerateFromPassword([]byte(r.Password), bcrypt.DefaultCost) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to process password") + } + + user := User{Username: r.Username, Password: string(hash)} + if err := db.Create(&user).Error; err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create user") + } + + token, err := generateToken(&user) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate token") + } + + return c.JSON(http.StatusCreated, map[string]interface{}{ + "token": token, + "user": map[string]interface{}{"id": user.ID, "username": user.Username}, + }) +} + +func login(c echo.Context) error { + type Req struct { + Username string `json:"username"` + Password string `json:"password"` + } + r := new(Req) + if err := c.Bind(r); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid request") + } + + var user User + if err := db.Where("username = ?", r.Username).First(&user).Error; err != nil { + return echo.NewHTTPError(http.StatusUnauthorized, "Invalid credentials") + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(r.Password)); err != nil { + return echo.NewHTTPError(http.StatusUnauthorized, "Invalid credentials") + } + + token, err := generateToken(&user) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to generate token") + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "token": token, + "user": map[string]interface{}{"id": user.ID, "username": user.Username}, + }) +} + +func getMe(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") + } + return c.JSON(http.StatusOK, map[string]interface{}{ + "id": user.ID, + "username": user.Username, + }) +} diff --git a/backend/go.mod b/backend/go.mod index 68405b3..426c817 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,12 +3,14 @@ module reduce go 1.20 require ( + github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/jinzhu/gorm v1.9.16 + github.com/joho/godotenv v1.5.1 github.com/labstack/echo v3.3.10+incompatible + golang.org/x/crypto v0.22.0 ) require ( - github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/lib/pq v1.10.9 // indirect @@ -17,7 +19,6 @@ require ( github.com/mattn/go-sqlite3 v1.14.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - golang.org/x/crypto v0.22.0 // indirect golang.org/x/net v0.24.0 // indirect golang.org/x/sys v0.19.0 // indirect golang.org/x/text v0.14.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index a1d8f47..41bc646 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -17,6 +17,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M= github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg= github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= diff --git a/backend/handlers.go b/backend/handlers.go new file mode 100644 index 0000000..0cbb121 --- /dev/null +++ b/backend/handlers.go @@ -0,0 +1,325 @@ +package main + +import ( + "math/rand" + "net/http" + "os" + "strconv" + "time" + + "github.com/jinzhu/gorm" + "github.com/labstack/echo" + "golang.org/x/crypto/bcrypt" +) + +func codegen(length int) string { + const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, length) + for i := range b { + b[i] = charset[rand.Intn(len(charset))] + } + return string(b) +} + +func codeLength() int { + if v := os.Getenv("CODE_LENGTH"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + return n + } + } + return 6 +} + +func uniqueCode() string { + for { + code := codegen(codeLength()) + var count int + db.Model(&Link{}).Where("code = ?", code).Count(&count) + if count == 0 { + return code + } + } +} + +// shortenURL creates a new shortened link +func shortenURL(c echo.Context) error { + type Req struct { + LongURL string `json:"lurl"` + BaseURL string `json:"base_url"` + Code string `json:"code"` + RequiresAuth bool `json:"requires_auth"` + AccessUsername string `json:"access_username"` + AccessPassword string `json:"access_password"` + } + + r := new(Req) + if err := c.Bind(r); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid request") + } + if r.LongURL == "" { + return echo.NewHTTPError(http.StatusBadRequest, "URL is required") + } + + if r.BaseURL == "" { + r.BaseURL = os.Getenv("BASE_URL") + if r.BaseURL == "" { + return echo.NewHTTPError(http.StatusInternalServerError, "Base URL not configured") + } + } + + // Authenticated user + var userID *uint + if uid, ok := c.Get("user_id").(uint); ok { + userID = &uid + } + + if r.RequiresAuth && userID == nil { + return echo.NewHTTPError(http.StatusBadRequest, "Login required to create protected links") + } + + // Determine short code + isCustom := false + code := r.Code + if code != "" { + isCustom = true + if len(code) < 2 || len(code) > 32 { + return echo.NewHTTPError(http.StatusBadRequest, "Custom code must be 2–32 characters") + } + var existing Link + if db.Where("code = ?", code).First(&existing).Error == nil { + return echo.NewHTTPError(http.StatusConflict, "Short code already taken") + } + } else { + code = uniqueCode() + } + + link := Link{ + UserID: userID, + Code: code, + LongURL: r.LongURL, + IsCustom: isCustom, + RequiresAuth: r.RequiresAuth, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if r.RequiresAuth { + // If no explicit credentials provided, use the logged-in user's credentials + if r.AccessUsername == "" && r.AccessPassword == "" && userID != nil { + var user User + if err := db.First(&user, *userID).Error; err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to load user") + } + link.AccessUsername = user.Username + link.AccessPassword = user.Password // Already hashed + } else if r.AccessUsername != "" && r.AccessPassword != "" { + // Custom credentials provided + hash, err := bcrypt.GenerateFromPassword([]byte(r.AccessPassword), bcrypt.DefaultCost) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to process password") + } + link.AccessUsername = r.AccessUsername + link.AccessPassword = string(hash) + } else { + return echo.NewHTTPError(http.StatusBadRequest, "Access credentials required for protected links") + } + } + + if err := db.Create(&link).Error; err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create link") + } + + return c.JSON(http.StatusCreated, map[string]interface{}{ + "surl": r.BaseURL + "/" + code, + "id": link.ID, + "code": code, + "long_url": link.LongURL, + "is_custom": link.IsCustom, + "requires_auth": link.RequiresAuth, + }) +} + +// fetchLURL resolves a short code to a long URL +func fetchLURL(c echo.Context) error { + code := c.Param("code") + var link Link + if err := db.Where("code = ?", code).First(&link).Error; err != nil { + return echo.NewHTTPError(http.StatusNotFound, "Link not found") + } + + if link.RequiresAuth { + return c.JSON(http.StatusOK, map[string]interface{}{ + "requires_auth": true, + "code": link.Code, + }) + } + + 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, + }) +} + +// verifyAndRedirect checks credentials for auth-protected links +func verifyAndRedirect(c echo.Context) error { + code := c.Param("code") + + type Req struct { + Username string `json:"username"` + Password string `json:"password"` + } + r := new(Req) + if err := c.Bind(r); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid request") + } + + var link Link + if err := db.Where("code = ?", code).First(&link).Error; err != nil { + return echo.NewHTTPError(http.StatusNotFound, "Link not found") + } + + if !link.RequiresAuth { + db.Model(&link).UpdateColumn("click_count", gorm.Expr("click_count + 1")) + return c.JSON(http.StatusOK, map[string]string{"lurl": link.LongURL}) + } + + if r.Username != link.AccessUsername { + return echo.NewHTTPError(http.StatusUnauthorized, "Invalid credentials") + } + + // Check if access_username matches a user account (owner's credentials) + var user User + if db.Where("username = ?", link.AccessUsername).First(&user).Error == nil { + // Verify against user's account password + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(r.Password)); err != nil { + return echo.NewHTTPError(http.StatusUnauthorized, "Invalid credentials") + } + } else { + // Verify against link's custom password + if err := bcrypt.CompareHashAndPassword([]byte(link.AccessPassword), []byte(r.Password)); err != nil { + return echo.NewHTTPError(http.StatusUnauthorized, "Invalid credentials") + } + } + + db.Model(&link).UpdateColumn("click_count", gorm.Expr("click_count + 1")) + return c.JSON(http.StatusOK, map[string]string{"lurl": link.LongURL}) +} + +// --- Dashboard handlers (authenticated) --- + +func listLinks(c echo.Context) error { + uid := c.Get("user_id").(uint) + var links []Link + if err := db.Where("user_id = ?", uid).Order("created_at desc").Find(&links).Error; err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to fetch links") + } + return c.JSON(http.StatusOK, links) +} + +func updateLink(c echo.Context) error { + uid := c.Get("user_id").(uint) + id := c.Param("id") + + var link Link + if err := db.First(&link, id).Error; err != nil { + return echo.NewHTTPError(http.StatusNotFound, "Link not found") + } + if link.UserID == nil || *link.UserID != uid { + return echo.NewHTTPError(http.StatusForbidden, "Access denied") + } + + type Req struct { + Code *string `json:"code"` + LongURL *string `json:"long_url"` + RequiresAuth *bool `json:"requires_auth"` + AccessUsername *string `json:"access_username"` + AccessPassword *string `json:"access_password"` + } + + r := new(Req) + if err := c.Bind(r); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid request") + } + + if r.Code != nil { + if len(*r.Code) < 2 || len(*r.Code) > 32 { + return echo.NewHTTPError(http.StatusBadRequest, "Code must be 2–32 characters") + } + var dup Link + if db.Where("code = ? AND id != ?", *r.Code, link.ID).First(&dup).Error == nil { + return echo.NewHTTPError(http.StatusConflict, "Short code already taken") + } + link.Code = *r.Code + link.IsCustom = true + } + + if r.LongURL != nil { + if *r.LongURL == "" { + return echo.NewHTTPError(http.StatusBadRequest, "URL cannot be empty") + } + link.LongURL = *r.LongURL + } + + if r.RequiresAuth != nil { + if *r.RequiresAuth { + uname := "" + if r.AccessUsername != nil { + uname = *r.AccessUsername + } + pass := "" + if r.AccessPassword != nil { + pass = *r.AccessPassword + } + if uname == "" || pass == "" { + return echo.NewHTTPError(http.StatusBadRequest, "Access credentials required for protected links") + } + hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to process password") + } + link.RequiresAuth = true + link.AccessUsername = uname + link.AccessPassword = string(hash) + } else { + link.RequiresAuth = false + link.AccessUsername = "" + link.AccessPassword = "" + } + } else if link.RequiresAuth { + // Auth already on — allow credential updates without toggling + if r.AccessUsername != nil && *r.AccessUsername != "" { + link.AccessUsername = *r.AccessUsername + } + if r.AccessPassword != nil && *r.AccessPassword != "" { + hash, _ := bcrypt.GenerateFromPassword([]byte(*r.AccessPassword), bcrypt.DefaultCost) + link.AccessPassword = string(hash) + } + } + + link.UpdatedAt = time.Now() + if err := db.Save(&link).Error; err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to update link") + } + + return c.JSON(http.StatusOK, link) +} + +func deleteLink(c echo.Context) error { + uid := c.Get("user_id").(uint) + id := c.Param("id") + + var link Link + if err := db.First(&link, id).Error; err != nil { + return echo.NewHTTPError(http.StatusNotFound, "Link not found") + } + if link.UserID == nil || *link.UserID != uid { + return echo.NewHTTPError(http.StatusForbidden, "Access denied") + } + + if err := db.Delete(&link).Error; err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to delete link") + } + return c.JSON(http.StatusOK, map[string]string{"message": "Link deleted"}) +} diff --git a/backend/main.go b/backend/main.go index 7981c44..bdc9c16 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,102 +1,16 @@ package main import ( - "log" - "math/rand" "net/http" - "time" "os" - "errors" - "strconv" - "github.com/jinzhu/gorm" - _ "github.com/jinzhu/gorm/dialects/sqlite" + "github.com/joho/godotenv" "github.com/labstack/echo" "github.com/labstack/echo/middleware" ) -func codegen(length int) string { - const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" - bytes := make([]byte, length) - for i := range bytes { - bytes[i] = charset[rand.Intn(len(charset))] - } - return string(bytes) -} - -func shortenURL(c echo.Context) error { - // Define a struct for binding the request body - type RequestBody struct { - LURL string `json:"lurl"` - BaseURL string `json:"base_url"` // Expect base URL in the request - } - - // Bind request body to the RequestBody struct - reqBody := new(RequestBody) - if err := c.Bind(reqBody); err != nil { - return err - } - - // Validate the base URL - if reqBody.BaseURL == "" { - // Fallback to BASE_URL environment variable - reqBody.BaseURL = os.Getenv("BASE_URL") - if reqBody.BaseURL == "" { - return echo.NewHTTPError(http.StatusInternalServerError, "Base URL is not configured") - } - } - - // Check if the long URL already exists in the database - var existingURL CodeURLMap - if err := db.Where("lurl = ?", reqBody.LURL).First(&existingURL).Error; err == nil { - // If the long URL exists, return the existing short URL - return c.JSON(http.StatusOK, map[string]string{ - "surl": reqBody.BaseURL + "/" + existingURL.Code, - }) - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - // If there's an error other than record not found, return an error - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to check existing URL") - } - - // Generate a unique code - codelen := 6 - if os.Getenv("CODE_LENGTH") != "" && os.Getenv("CODE_LENGTH") != "0" { - t, err := strconv.Atoi(os.Getenv("CODE_LENGTH")) - if err == nil { - codelen = t - } - } - code := codegen(codelen) - - // Create URL record - url := &CodeURLMap{ - Code: code, - LURL: reqBody.LURL, - CreatedAt: time.Now(), - } - - // Save URL record to the database - if err := db.Create(url).Error; err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create URL record") - } - - return c.JSON(http.StatusCreated, map[string]string{ - "surl": reqBody.BaseURL + "/" + code, - }) -} - -func fetchLURL(c echo.Context) error { - code := c.Param("code") - var url CodeURLMap - if err := db.Where("code = ?", code).First(&url).Error; err != nil { - log.Println("Error retrieving URL:", err) - return echo.NewHTTPError(http.StatusNotFound, "URL not found") - } - return c.JSON(http.StatusOK, map[string]string{"lurl": url.LURL}) -} - - func main() { + godotenv.Load() defer db.Close() e := echo.New() @@ -107,15 +21,33 @@ func main() { 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}, })) - // Routes + // Health e.GET("/", func(c echo.Context) error { return c.String(http.StatusOK, "Backend is running alright.\n") }) - e.POST("/reduce/shorten", shortenURL) - e.GET("/reduce/:code", fetchLURL) + // Auth + e.POST("/auth/register", register) + e.POST("/auth/login", login) + e.GET("/auth/me", getMe, JWTMiddleware) - e.Logger.Fatal(e.Start(":8080")) + // Public link routes (optional auth for shorten) + e.POST("/reduce/shorten", shortenURL, OptionalJWTMiddleware) + e.GET("/reduce/:code", fetchLURL) + e.POST("/reduce/:code/verify", verifyAndRedirect) + + // Authenticated link management + links := e.Group("/links", JWTMiddleware) + links.GET("", listLinks) + links.PUT("/:id", updateLink) + links.DELETE("/:id", deleteLink) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + e.Logger.Fatal(e.Start(":" + port)) } diff --git a/backend/store.go b/backend/store.go index e64ddb2..4554710 100644 --- a/backend/store.go +++ b/backend/store.go @@ -2,17 +2,35 @@ package main import ( "fmt" + "os" "time" + "github.com/jinzhu/gorm" _ "github.com/jinzhu/gorm/dialects/sqlite" - - "os" ) -type CodeURLMap struct { - Code string `gorm:"primary_key" json:"code"` - LURL string `json:"lurl" gorm:"column:lurl"` +// User represents a registered account +type User struct { + ID uint `gorm:"primary_key" json:"id"` + Username string `gorm:"unique_index;not null;size:32" json:"username"` + Password string `gorm:"not null" json:"-"` CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Link represents a shortened URL +type Link struct { + ID uint `gorm:"primary_key" json:"id"` + UserID *uint `gorm:"index" json:"user_id"` + Code string `gorm:"unique_index;not null;size:32" json:"code"` + LongURL string `gorm:"not null;column:long_url" json:"long_url"` + IsCustom bool `gorm:"default:false" json:"is_custom"` + RequiresAuth bool `gorm:"default:false" json:"requires_auth"` + AccessUsername string `gorm:"column:access_username;size:64" json:"access_username,omitempty"` + AccessPassword string `gorm:"column:access_password" json:"-"` + ClickCount int `gorm:"default:0" json:"click_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } var db *gorm.DB @@ -29,6 +47,8 @@ func init() { panic(fmt.Sprintf("Failed to connect to database: %v", err)) } - // Auto-migrate database - db.AutoMigrate(&CodeURLMap{}) + db.Exec("PRAGMA foreign_keys = ON") + db.Exec("PRAGMA journal_mode = WAL") + + db.AutoMigrate(&User{}, &Link{}) } diff --git a/compose.yml b/compose.yml deleted file mode 100644 index 360b3e8..0000000 --- a/compose.yml +++ /dev/null @@ -1,28 +0,0 @@ -services: - backend: - build: ./backend - environment: - DB_PATH: /data/reduce.db - ports: - - "8080:8080" - platform: linux/amd64 - volumes: - - ./data:/data - networks: - - docker - - frontend: - build: ./frontend - environment: - PUBLIC_BACKEND_URL: http://localhost:8080 - INTERNAL_BACKEND_URL: http://backend:8080 - ports: - - "3000:3000" - depends_on: - - backend - networks: - - docker - -networks: - docker: - driver: bridge diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 727186c..885b4dd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,15 +1,26 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { AuthProvider } from './context/AuthContext'; +import Navbar from './components/Navbar'; import Home from './pages/Home'; import Redirect from './pages/Redirect'; +import Login from './pages/Login'; +import Register from './pages/Register'; +import Links from './pages/Links'; function App() { return ( - - - } /> - } /> - - + + + + + } /> + } /> + } /> + } /> + } /> + + + ); } diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx new file mode 100644 index 0000000..b13fb45 --- /dev/null +++ b/frontend/src/components/Navbar.tsx @@ -0,0 +1,56 @@ +import { Link } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; +import { MdPerson, MdLogout, MdLink } from 'react-icons/md'; + +export default function Navbar() { + const { user, logout } = useAuth(); + + return ( + + ); +} diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx new file mode 100644 index 0000000..3e58ab7 --- /dev/null +++ b/frontend/src/context/AuthContext.tsx @@ -0,0 +1,72 @@ +import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import api from '../lib/api'; + +interface User { + id: number; + username: string; +} + +interface AuthContextType { + user: User | null; + token: string | null; + login: (username: string, password: string) => Promise; + register: (username: string, password: string) => Promise; + logout: () => void; + isLoading: boolean; +} + +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]); + + 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); + setUser(null); + }; + + return ( + + {children} + + ); +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within AuthProvider'); + return ctx; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..96f202b --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,15 @@ +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; +}); + +export default api; diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 6047a87..a381172 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,52 +1,68 @@ import { useState } from 'react'; -import axios from 'axios'; -import { MdContentCopy, MdCheck, MdRefresh } from 'react-icons/md'; +import { MdContentCopy, MdCheck, MdRefresh, MdLock, MdTune } from 'react-icons/md'; import QRCode from 'react-qr-code'; +import { useAuth } from '../context/AuthContext'; +import api from '../lib/api'; export default function Home() { - const [longUrl, setLongUrl] = useState(""); - const [shortUrl, setShortUrl] = useState(""); + const { user } = useAuth(); + const [longUrl, setLongUrl] = useState(''); + const [shortUrl, setShortUrl] = useState(''); const [copied, setCopied] = useState(false); - const [prevLongUrl, setPrevLongUrl] = useState(""); - const [status, setStatus] = useState<{ type: 'error' | 'success' | 'idle', msg: string }>({ type: 'idle', msg: '' }); + const [prevLongUrl, setPrevLongUrl] = useState(''); + const [status, setStatus] = useState<{ type: 'error' | 'success' | 'idle'; msg: string }>({ + type: 'idle', + msg: '', + }); + + // Advanced options (logged-in users) + const [showAdvanced, setShowAdvanced] = useState(false); + const [customCode, setCustomCode] = useState(''); + const [requiresAuth, setRequiresAuth] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setStatus({ type: 'idle', msg: '' }); - if (longUrl === prevLongUrl && shortUrl) { + if (longUrl === prevLongUrl && shortUrl) return; + if (longUrl.trim() === '') return; + + try { + new URL(longUrl); + } catch { + setStatus({ type: 'error', msg: 'Invalid URL' }); return; } - if (longUrl.trim() === "") { - return; + if (!user && requiresAuth) { + setStatus({ type: 'error', msg: 'Login required for protected links' }); + return; } - try { - new URL(longUrl); - } catch (_) { - setStatus({ type: 'error', msg: 'Invalid URL' }); - return; - } - - const baseURL = window.location.origin; - const backendUrl = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8080'; + // Use r.webark.in for short links in production, otherwise use current origin + const baseURL = window.location.hostname === 'r.webark.in' + ? 'https://r.webark.in' + : window.location.origin; try { - const response = await axios.post( - `${backendUrl}/reduce/shorten`, - { - lurl: longUrl, - base_url: baseURL, - }, - ); + const payload: Record = { + lurl: longUrl, + base_url: baseURL, + }; + + if (user && customCode) payload.code = customCode; + if (user && requiresAuth) { + payload.requires_auth = true; + } + + const response = await api.post('/reduce/shorten', payload); setShortUrl(response.data.surl); setPrevLongUrl(longUrl); setStatus({ type: 'success', msg: '' }); - } catch (error) { - console.error("Error shortening URL:", error); - setStatus({ type: 'error', msg: 'Error' }); + } catch (error: any) { + const msg = error.response?.data?.message || 'Error'; + setStatus({ type: 'error', msg }); } }; @@ -57,80 +73,143 @@ export default function Home() { }; const handleReset = () => { - setLongUrl(""); - setShortUrl(""); - setPrevLongUrl(""); + setLongUrl(''); + setShortUrl(''); + setPrevLongUrl(''); setStatus({ type: 'idle', msg: '' }); setCopied(false); + setCustomCode(''); + setRequiresAuth(false); }; return ( -
+
{/* Header */}
-

- Reduce -

+

Reduce

+ {!user && ( +

+ Login to create custom codes & manage links +

+ )}
{/* Main Interface */}
- {!shortUrl ? ( - <> -
- setLongUrl(e.target.value)} - className="w-full bg-zinc-950 border border-zinc-700 p-3 text-zinc-300 placeholder-zinc-700 focus:outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 transition-all font-mono text-sm" - /> - - -
- - {/* Error Indicator */} - {status.msg && status.type === 'error' && ( -
- {status.msg} -
- )} - - ) : ( -
-
- - -
- -
- -
+ {!shortUrl ? ( + <> +
+ setLongUrl(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-700 p-3 text-zinc-300 placeholder-zinc-700 focus:outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 transition-all font-mono text-sm" + /> + {/* Advanced options for logged-in users */} + {user && ( + <> + + {showAdvanced && ( +
+ setCustomCode(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-700 p-3 text-zinc-300 placeholder-zinc-700 focus:outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 transition-all font-mono text-sm" + minLength={2} + maxLength={32} + /> + +
diff --git a/frontend/src/pages/Links.tsx b/frontend/src/pages/Links.tsx new file mode 100644 index 0000000..489f982 --- /dev/null +++ b/frontend/src/pages/Links.tsx @@ -0,0 +1,418 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; +import api from '../lib/api'; +import { + MdAdd, + MdEdit, + MdDelete, + MdContentCopy, + MdCheck, + MdLock, + MdClose, + MdOpenInNew, + MdLink, +} from 'react-icons/md'; + +interface LinkItem { + id: number; + user_id: number | null; + code: string; + long_url: string; + is_custom: boolean; + requires_auth: boolean; + access_username: string; + click_count: number; + created_at: string; + updated_at: string; +} + +interface ModalState { + open: boolean; + mode: 'create' | 'edit'; + link?: LinkItem; +} + +export default function Links() { + const { user, isLoading } = useAuth(); + const navigate = useNavigate(); + const [links, setLinks] = useState([]); + const [loading, setLoading] = useState(true); + const [copiedId, setCopiedId] = useState(null); + const [modal, setModal] = useState({ open: false, mode: 'create' }); + const [error, setError] = useState(''); + + useEffect(() => { + if (!isLoading && !user) { + navigate('/login'); + } + }, [user, isLoading, navigate]); + + useEffect(() => { + if (user) fetchLinks(); + }, [user]); + + const fetchLinks = async () => { + try { + const res = await api.get('/links'); + setLinks(res.data || []); + } catch { + setError('Failed to load links'); + } finally { + setLoading(false); + } + }; + + const handleDelete = async (id: number) => { + if (!window.confirm('Delete this link permanently?')) return; + try { + await api.delete(`/links/${id}`); + setLinks((prev) => prev.filter((l) => l.id !== id)); + } catch { + setError('Failed to delete link'); + } + }; + + const handleCopy = (code: string, id: number) => { + const url = `${window.location.origin}/${code}`; + navigator.clipboard.writeText(url); + setCopiedId(id); + setTimeout(() => setCopiedId(null), 2000); + }; + + const baseUrl = window.location.origin; + + if (isLoading || loading) { + return ( +
+
Loading...
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+

Your Links

+ +
+ + {error && ( +
+ {error} +
+ )} + + {/* Links */} + {links.length === 0 ? ( +
+ +

No links yet

+ +
+ ) : ( +
+ {links.map((link) => ( +
+ {/* Link info */} +
+
+ + {baseUrl}/{link.code} + + {link.is_custom && ( + + Custom + + )} + {link.requires_auth && ( + + Protected + + )} +
+

{link.long_url}

+

+ {link.click_count} click{link.click_count !== 1 ? 's' : ''} ·{' '} + {new Date(link.created_at).toLocaleDateString()} +

+
+ + {/* Actions */} +
+ + + + + + +
+
+ ))} +
+ )} +
+ + {/* Modal */} + {modal.open && ( + setModal({ open: false, mode: 'create' })} + onSaved={fetchLinks} + /> + )} +
+ ); +} + +// ---------- Link Create/Edit Modal ---------- + +interface LinkModalProps { + mode: 'create' | 'edit'; + link?: LinkItem; + onClose: () => void; + onSaved: () => void; +} + +function LinkModal({ mode, link, onClose, onSaved }: LinkModalProps) { + const { user } = useAuth(); + const [longUrl, setLongUrl] = useState(link?.long_url || ''); + const [code, setCode] = useState(link?.code || ''); + const [requiresAuth, setRequiresAuth] = useState(link?.requires_auth || false); + const [useOwnCredentials, setUseOwnCredentials] = useState(true); + const [accessUsername, setAccessUsername] = useState(link?.access_username || ''); + const [accessPassword, setAccessPassword] = useState(''); + const [error, setError] = useState(''); + const [saving, setSaving] = useState(false); + + // If editing a link with custom credentials (not matching user), default to custom mode + useEffect(() => { + if (link && link.access_username && link.access_username !== user?.username) { + setUseOwnCredentials(false); + } + }, [link, user]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setSaving(true); + + try { + if (mode === 'create') { + // Use r.webark.in for short links in production, otherwise use current origin + const baseURL = window.location.hostname === 'r.webark.in' + ? 'https://r.webark.in' + : window.location.origin; + await api.post('/reduce/shorten', { + lurl: longUrl, + base_url: baseURL, + code: code || undefined, + requires_auth: requiresAuth, + access_username: requiresAuth && accessUsername ? accessUsername : undefined, + access_password: requiresAuth && accessPassword ? accessPassword : undefined, + }); + } else if (link) { + const body: Record = {}; + if (code !== link.code) body.code = code; + if (longUrl !== link.long_url) body.long_url = longUrl; + if (requiresAuth !== link.requires_auth) body.requires_auth = requiresAuth; + if (requiresAuth) { + if (accessUsername) body.access_username = accessUsername; + if (accessPassword) body.access_password = accessPassword; + } + await api.put(`/links/${link.id}`, body); + } + onSaved(); + onClose(); + } catch (err: any) { + setError(err.response?.data?.message || 'Something went wrong'); + } finally { + setSaving(false); + } + }; + + return ( +
+
+ {/* Header */} +
+

+ {mode === 'create' ? 'New Link' : 'Edit Link'} +

+ +
+ + {/* Form */} +
+
+ + setLongUrl(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-700 p-3 text-zinc-300 placeholder-zinc-700 focus:outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 transition-all font-mono text-sm" + required + /> +
+ +
+ + setCode(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-700 p-3 text-zinc-300 placeholder-zinc-700 focus:outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 transition-all font-mono text-sm" + minLength={2} + maxLength={32} + /> +
+ + {/* Requires Auth Toggle */} +
+ ); +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..e282908 --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,79 @@ +import { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + +export default function Login() { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const { login, user } = useAuth(); + const navigate = useNavigate(); + + if (user) { + navigate('/links'); + return null; + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + try { + await login(username, password); + navigate('/links'); + } catch (err: any) { + setError(err.response?.data?.message || 'Login failed'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

+ Login +

+
+
+ setUsername(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-700 p-3 text-zinc-300 placeholder-zinc-700 focus:outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 transition-all font-mono text-sm" + required + /> + setPassword(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-700 p-3 text-zinc-300 placeholder-zinc-700 focus:outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 transition-all font-mono text-sm" + required + /> + +
+ {error && ( +
+ {error} +
+ )} +

+ No account?{' '} + + Register + +

+
+
+
+ ); +} diff --git a/frontend/src/pages/Redirect.tsx b/frontend/src/pages/Redirect.tsx index d499a65..53ca4d2 100644 --- a/frontend/src/pages/Redirect.tsx +++ b/frontend/src/pages/Redirect.tsx @@ -1,29 +1,31 @@ import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; -import axios from 'axios'; -import { MdErrorOutline } from "react-icons/md"; +import { MdErrorOutline, MdLock } from 'react-icons/md'; +import api from '../lib/api'; export default function Redirect() { const { code } = useParams(); const [error, setError] = useState(false); + const [needsAuth, setNeedsAuth] = useState(false); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [authError, setAuthError] = useState(''); + const [verifying, setVerifying] = useState(false); useEffect(() => { const fetchUrl = async () => { if (!code) return; - - const backendUrl = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8080'; try { - const response = await axios.get( - `${backendUrl}/reduce/${code}` - ); - if (response.status === 200 && response.data.lurl) { + const response = await api.get(`/reduce/${code}`); + if (response.data.requires_auth) { + setNeedsAuth(true); + } else if (response.data.lurl) { window.location.replace(response.data.lurl); } else { setError(true); } - } catch (err) { - console.error("Redirect error:", err); + } catch { setError(true); } }; @@ -31,32 +33,101 @@ export default function Redirect() { fetchUrl(); }, [code]); + const handleVerify = async (e: React.FormEvent) => { + e.preventDefault(); + setAuthError(''); + setVerifying(true); + + try { + const res = await api.post(`/reduce/${code}/verify`, { username, password }); + if (res.data.lurl) { + window.location.replace(res.data.lurl); + } else { + setAuthError('Unexpected response'); + } + } catch (err: any) { + setAuthError(err.response?.data?.message || 'Invalid credentials'); + } finally { + setVerifying(false); + } + }; + if (error) { return ( -
-
-
- -
-

404 Not Found

-

- Link invalid or expired. -

- - - Go Home - - +
+
+
+
+

+ 404 Not Found +

+

Link invalid or expired.

+ + + Go Home + +
+
+ ); + } + + if (needsAuth) { + return ( +
+
+
+
+ +
+
+

+ Protected Link +

+

+ Enter credentials to continue +

+
+ setUsername(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-700 p-3 text-zinc-300 placeholder-zinc-700 focus:outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 transition-all font-mono text-sm" + required + /> + setPassword(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-700 p-3 text-zinc-300 placeholder-zinc-700 focus:outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 transition-all font-mono text-sm" + required + /> + +
+ {authError && ( +
+ {authError} +
+ )} +
+
); } return (
-
- Redirecting... -
+
+ Redirecting... +
); } diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx new file mode 100644 index 0000000..651b230 --- /dev/null +++ b/frontend/src/pages/Register.tsx @@ -0,0 +1,98 @@ +import { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + +export default function Register() { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const { register, user } = useAuth(); + const navigate = useNavigate(); + + if (user) { + navigate('/links'); + return null; + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + if (password !== confirm) { + setError('Passwords do not match'); + return; + } + + setLoading(true); + try { + await register(username, password); + navigate('/links'); + } catch (err: any) { + setError(err.response?.data?.message || 'Registration failed'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

+ Register +

+
+
+ setUsername(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-700 p-3 text-zinc-300 placeholder-zinc-700 focus:outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 transition-all font-mono text-sm" + required + minLength={3} + maxLength={32} + /> + setPassword(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-700 p-3 text-zinc-300 placeholder-zinc-700 focus:outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 transition-all font-mono text-sm" + required + minLength={6} + /> + setConfirm(e.target.value)} + className="w-full bg-zinc-950 border border-zinc-700 p-3 text-zinc-300 placeholder-zinc-700 focus:outline-none focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400 transition-all font-mono text-sm" + required + minLength={6} + /> + +
+ {error && ( +
+ {error} +
+ )} +

+ Already have an account?{' '} + + Login + +

+
+
+
+ ); +}