feat: version 2

This commit is contained in:
Arkaprabha Chakraborty
2026-02-12 05:51:56 +05:30
parent 1ecd710191
commit 005838045a
20 changed files with 1645 additions and 334 deletions

118
README.md
View File

@@ -2,77 +2,99 @@
# Reduce # 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 ## Features
- URL shortening: Convert long URLs into short, easy-to-share URLs. - **Shorten URLs** — works anonymously or logged-in
- Automatic URL retrieval: Easily retrieve the original long URL using the short URL. - **User accounts** — register / login with username & password (JWT)
- Unique ID generation: Each shortened URL is given a unique, randomly generated ID. - **Custom short codes** — choose your own slug (logged-in users)
- Responsive UI: User-friendly interface with React, Tailwind CSS, and MUI components. - **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 & Short links**: https://r.webark.in
- **Frontend**: React, Axios, Tailwind CSS, MUI - **API**: https://api.r.webark.in
- **Database**: PostgreSQL
- **Containerization**: Docker, Docker Compose
## 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 ## API
- Node.js and npm installed
### 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 |
``` ### Links (public)
DB_HOST=host
DB_PORT=port
DB_USER=user
DB_NAME=name
DB_PASSWORD=password
BASE_URL=url
```
2. Build and run the backend using Docker Compose: | Method | Path | Auth | Description |
```sh | ------ | ---------------------- | -------- | ------------------------------------- |
docker-compose up --build | 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 ## Quick Start
cd frontend
```
2. Create a `.env.local` file with the following variable: ### Backend
``` ```bash
NEXT_PUBLIC_BACKEND_URL=http://localhost:8080 cd backend
``` # Edit .env and set a secure JWT_SECRET:
# openssl rand -base64 32
go run .
```
3. Install the dependencies and run the frontend: ### Frontend
```sh
npm install
npm run dev
```
## Usage ```bash
cd frontend
npm install
npm run dev
```
1. Open your browser and navigate to `http://localhost:3000`. ### Docker
2. Enter the long URL you want to shorten and click the "Reduce" button.
3. Copy the shortened URL and share it as needed. ```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 ## 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 ## 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.

View File

@@ -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:

8
backend/.gitignore vendored
View File

@@ -7,5 +7,9 @@ pkg/
*.test *.test
*.prof *.prof
.env .env
# Local database (if applicable)
reduce.db # Database
reduce.db*
# Binary
reduce

View File

@@ -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"]

171
backend/auth.go Normal file
View File

@@ -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 332 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,
})
}

View File

@@ -3,12 +3,14 @@ module reduce
go 1.20 go 1.20
require ( require (
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/jinzhu/gorm v1.9.16 github.com/jinzhu/gorm v1.9.16
github.com/joho/godotenv v1.5.1
github.com/labstack/echo v3.3.10+incompatible github.com/labstack/echo v3.3.10+incompatible
golang.org/x/crypto v0.22.0
) )
require ( require (
github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect github.com/labstack/gommon v0.4.2 // indirect
github.com/lib/pq v1.10.9 // 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/mattn/go-sqlite3 v1.14.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // 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/net v0.24.0 // indirect
golang.org/x/sys v0.19.0 // indirect golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect golang.org/x/text v0.14.0 // indirect

View File

@@ -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/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 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M=
github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 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 h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg=
github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s= github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=

325
backend/handlers.go Normal file
View File

@@ -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 232 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 232 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"})
}

View File

@@ -1,102 +1,16 @@
package main package main
import ( import (
"log"
"math/rand"
"net/http" "net/http"
"time"
"os" "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"
"github.com/labstack/echo/middleware" "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() { func main() {
godotenv.Load()
defer db.Close() defer db.Close()
e := echo.New() e := echo.New()
@@ -107,15 +21,33 @@ func main() {
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"*"}, AllowOrigins: []string{"*"},
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}, 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 { e.GET("/", func(c echo.Context) error {
return c.String(http.StatusOK, "Backend is running alright.\n") return c.String(http.StatusOK, "Backend is running alright.\n")
}) })
e.POST("/reduce/shorten", shortenURL) // Auth
e.GET("/reduce/:code", fetchLURL) 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))
} }

View File

@@ -2,17 +2,35 @@ package main
import ( import (
"fmt" "fmt"
"os"
"time" "time"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/sqlite" _ "github.com/jinzhu/gorm/dialects/sqlite"
"os"
) )
type CodeURLMap struct { // User represents a registered account
Code string `gorm:"primary_key" json:"code"` type User struct {
LURL string `json:"lurl" gorm:"column:lurl"` 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"` 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 var db *gorm.DB
@@ -29,6 +47,8 @@ func init() {
panic(fmt.Sprintf("Failed to connect to database: %v", err)) panic(fmt.Sprintf("Failed to connect to database: %v", err))
} }
// Auto-migrate database db.Exec("PRAGMA foreign_keys = ON")
db.AutoMigrate(&CodeURLMap{}) db.Exec("PRAGMA journal_mode = WAL")
db.AutoMigrate(&User{}, &Link{})
} }

View File

@@ -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

View File

@@ -1,15 +1,26 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import Navbar from './components/Navbar';
import Home from './pages/Home'; import Home from './pages/Home';
import Redirect from './pages/Redirect'; import Redirect from './pages/Redirect';
import Login from './pages/Login';
import Register from './pages/Register';
import Links from './pages/Links';
function App() { function App() {
return ( return (
<BrowserRouter> <AuthProvider>
<Routes> <BrowserRouter>
<Route path="/" element={<Home />} /> <Navbar />
<Route path="/:code" element={<Redirect />} /> <Routes>
</Routes> <Route path="/" element={<Home />} />
</BrowserRouter> <Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/links" element={<Links />} />
<Route path="/:code" element={<Redirect />} />
</Routes>
</BrowserRouter>
</AuthProvider>
); );
} }

View File

@@ -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 (
<nav className="fixed top-0 w-full bg-zinc-950/80 backdrop-blur border-b border-zinc-800 z-50">
<div className="max-w-5xl mx-auto px-4 h-14 flex items-center justify-between">
<Link to="/" className="text-zinc-100 font-bold tracking-widest uppercase text-sm">
Reduce
</Link>
<div className="flex items-center gap-4">
{user ? (
<>
<Link
to="/links"
className="flex items-center gap-1.5 text-zinc-400 hover:text-zinc-100 transition-colors text-sm"
>
<MdLink size={16} />
Links
</Link>
<span className="text-zinc-600 text-xs font-mono flex items-center gap-1">
<MdPerson size={14} />
{user.username}
</span>
<button
onClick={logout}
className="text-zinc-500 hover:text-red-400 transition-colors"
title="Logout"
>
<MdLogout size={16} />
</button>
</>
) : (
<>
<Link
to="/login"
className="text-zinc-400 hover:text-zinc-100 transition-colors text-sm"
>
Login
</Link>
<Link
to="/register"
className="bg-zinc-200 text-zinc-900 px-3 py-1.5 text-xs font-bold uppercase tracking-widest hover:bg-white transition-colors"
>
Register
</Link>
</>
)}
</div>
</div>
</nav>
);
}

View File

@@ -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<void>;
register: (username: string, password: string) => Promise<void>;
logout: () => void;
isLoading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(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 (
<AuthContext.Provider value={{ user, token, login, register, logout, isLoading }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}

15
frontend/src/lib/api.ts Normal file
View File

@@ -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;

View File

@@ -1,52 +1,68 @@
import { useState } from 'react'; import { useState } from 'react';
import axios from 'axios'; import { MdContentCopy, MdCheck, MdRefresh, MdLock, MdTune } from 'react-icons/md';
import { MdContentCopy, MdCheck, MdRefresh } from 'react-icons/md';
import QRCode from 'react-qr-code'; import QRCode from 'react-qr-code';
import { useAuth } from '../context/AuthContext';
import api from '../lib/api';
export default function Home() { export default function Home() {
const [longUrl, setLongUrl] = useState(""); const { user } = useAuth();
const [shortUrl, setShortUrl] = useState(""); const [longUrl, setLongUrl] = useState('');
const [shortUrl, setShortUrl] = useState('');
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [prevLongUrl, setPrevLongUrl] = useState(""); const [prevLongUrl, setPrevLongUrl] = useState('');
const [status, setStatus] = useState<{ type: 'error' | 'success' | 'idle', msg: string }>({ type: 'idle', msg: '' }); 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setStatus({ type: 'idle', msg: '' }); 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; return;
} }
if (longUrl.trim() === "") { if (!user && requiresAuth) {
return; setStatus({ type: 'error', msg: 'Login required for protected links' });
return;
} }
try { // Use r.webark.in for short links in production, otherwise use current origin
new URL(longUrl); const baseURL = window.location.hostname === 'r.webark.in'
} catch (_) { ? 'https://r.webark.in'
setStatus({ type: 'error', msg: 'Invalid URL' }); : window.location.origin;
return;
}
const baseURL = window.location.origin;
const backendUrl = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8080';
try { try {
const response = await axios.post( const payload: Record<string, any> = {
`${backendUrl}/reduce/shorten`, lurl: longUrl,
{ base_url: baseURL,
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); setShortUrl(response.data.surl);
setPrevLongUrl(longUrl); setPrevLongUrl(longUrl);
setStatus({ type: 'success', msg: '' }); setStatus({ type: 'success', msg: '' });
} catch (error) { } catch (error: any) {
console.error("Error shortening URL:", error); const msg = error.response?.data?.message || 'Error';
setStatus({ type: 'error', msg: 'Error' }); setStatus({ type: 'error', msg });
} }
}; };
@@ -57,80 +73,143 @@ export default function Home() {
}; };
const handleReset = () => { const handleReset = () => {
setLongUrl(""); setLongUrl('');
setShortUrl(""); setShortUrl('');
setPrevLongUrl(""); setPrevLongUrl('');
setStatus({ type: 'idle', msg: '' }); setStatus({ type: 'idle', msg: '' });
setCopied(false); setCopied(false);
setCustomCode('');
setRequiresAuth(false);
}; };
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen p-4"> <div className="flex flex-col items-center justify-center min-h-screen p-4 pt-20">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
{/* Header */} {/* Header */}
<header className="mb-8 text-center"> <header className="mb-8 text-center">
<h1 className="text-3xl font-bold tracking-widest text-zinc-100 uppercase"> <h1 className="text-3xl font-bold tracking-widest text-zinc-100 uppercase">Reduce</h1>
Reduce {!user && (
</h1> <p className="text-zinc-600 text-xs mt-2 font-mono">
Login to create custom codes &amp; manage links
</p>
)}
</header> </header>
{/* Main Interface */} {/* Main Interface */}
<div className="bg-zinc-900 border border-zinc-800 p-6 md:p-8"> <div className="bg-zinc-900 border border-zinc-800 p-6 md:p-8">
{!shortUrl ? ( {!shortUrl ? (
<> <>
<form onSubmit={handleSubmit} className="flex flex-col gap-4"> <form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input <input
type="text" type="text"
placeholder="https://..." placeholder="https://..."
value={longUrl} value={longUrl}
onChange={(e) => setLongUrl(e.target.value)} onChange={(e) => 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" 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"
/> />
<button
type="submit"
className="w-full bg-zinc-200 text-zinc-950 font-bold uppercase tracking-widest py-3 hover:bg-white transition-colors border border-transparent active:border-zinc-400 active:scale-[0.99]"
>
Reduce
</button>
</form>
{/* Error Indicator */}
{status.msg && status.type === 'error' && (
<div className="mt-4 text-xs font-mono uppercase tracking-wide border-l-2 pl-3 py-1 border-red-500 text-red-400">
{status.msg}
</div>
)}
</>
) : (
<div className="flex flex-col gap-6">
<div className="flex gap-2">
<input
readOnly
value={shortUrl}
className="w-full bg-zinc-950 border border-zinc-700 p-3 text-emerald-400 font-mono text-sm focus:outline-none"
/>
<button
onClick={handleCopy}
className="bg-zinc-800 border border-zinc-700 text-zinc-300 p-3 hover:bg-zinc-700 hover:text-white transition-colors"
aria-label="Copy"
>
{copied ? <MdCheck size={20} /> : <MdContentCopy size={20} />}
</button>
</div>
<div className="flex justify-center bg-white p-4">
<QRCode value={shortUrl} size={150} style={{ height: "auto", maxWidth: "100%", width: "100%" }} />
</div>
{/* Advanced options for logged-in users */}
{user && (
<>
<button <button
onClick={handleReset} type="button"
className="w-full bg-zinc-800 text-zinc-300 font-bold uppercase tracking-widest py-3 hover:bg-zinc-700 hover:text-white transition-colors border border-transparent flex items-center justify-center gap-2" onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-1.5 text-zinc-600 hover:text-zinc-400 transition-colors text-xs uppercase tracking-wider self-start"
> >
<MdRefresh size={20} /> Use Again <MdTune size={14} />
{showAdvanced ? 'Hide options' : 'Options'}
</button> </button>
{showAdvanced && (
<div className="flex flex-col gap-3 border-t border-zinc-800 pt-3">
<input
type="text"
placeholder="Custom short code (optional)"
value={customCode}
onChange={(e) => 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}
/>
<label className="flex items-center gap-3 cursor-pointer group">
<div
className={`w-9 h-5 rounded-full relative transition-colors ${
requiresAuth ? 'bg-amber-500' : 'bg-zinc-700'
}`}
onClick={() => setRequiresAuth(!requiresAuth)}
>
<div
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
requiresAuth ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</div>
<span className="text-zinc-500 text-xs uppercase tracking-wider flex items-center gap-1 group-hover:text-zinc-300 transition-colors">
<MdLock size={12} /> Protected
</span>
</label>
{requiresAuth && (
<div className="pl-4 border-l-2 border-amber-900/50">
<p className="text-zinc-600 text-xs">
Visitors will need to enter your account credentials to access this link.
</p>
</div>
)}
</div>
)}
</>
)}
<button
type="submit"
className="w-full bg-zinc-200 text-zinc-950 font-bold uppercase tracking-widest py-3 hover:bg-white transition-colors border border-transparent active:border-zinc-400 active:scale-[0.99]"
>
Reduce
</button>
</form>
{/* Error Indicator */}
{status.msg && status.type === 'error' && (
<div className="mt-4 text-xs font-mono uppercase tracking-wide border-l-2 pl-3 py-1 border-red-500 text-red-400">
{status.msg}
</div> </div>
)} )}
</>
) : (
<div className="flex flex-col gap-6">
<div className="flex gap-2">
<input
readOnly
value={shortUrl}
className="w-full bg-zinc-950 border border-zinc-700 p-3 text-emerald-400 font-mono text-sm focus:outline-none"
/>
<button
onClick={handleCopy}
className="bg-zinc-800 border border-zinc-700 text-zinc-300 p-3 hover:bg-zinc-700 hover:text-white transition-colors"
aria-label="Copy"
>
{copied ? <MdCheck size={20} /> : <MdContentCopy size={20} />}
</button>
</div>
<div className="flex justify-center bg-white p-4">
<QRCode
value={shortUrl}
size={150}
style={{ height: 'auto', maxWidth: '100%', width: '100%' }}
/>
</div>
<button
onClick={handleReset}
className="w-full bg-zinc-800 text-zinc-300 font-bold uppercase tracking-widest py-3 hover:bg-zinc-700 hover:text-white transition-colors border border-transparent flex items-center justify-center gap-2"
>
<MdRefresh size={20} /> Use Again
</button>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -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<LinkItem[]>([]);
const [loading, setLoading] = useState(true);
const [copiedId, setCopiedId] = useState<number | null>(null);
const [modal, setModal] = useState<ModalState>({ 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 (
<div className="flex items-center justify-center min-h-screen">
<div className="font-mono text-zinc-500 text-sm tracking-widest animate-pulse">Loading...</div>
</div>
);
}
return (
<div className="min-h-screen pt-20 pb-12 px-4">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<h1 className="text-2xl font-bold tracking-widest text-zinc-100 uppercase">Your Links</h1>
<button
onClick={() => setModal({ open: true, mode: 'create' })}
className="flex items-center gap-1.5 bg-zinc-200 text-zinc-900 px-4 py-2 text-xs font-bold uppercase tracking-widest hover:bg-white transition-colors"
>
<MdAdd size={16} /> New Link
</button>
</div>
{error && (
<div className="mb-6 text-xs font-mono uppercase tracking-wide border-l-2 pl-3 py-1 border-red-500 text-red-400">
{error}
</div>
)}
{/* Links */}
{links.length === 0 ? (
<div className="bg-zinc-900 border border-zinc-800 p-12 text-center">
<MdLink size={40} className="mx-auto mb-4 text-zinc-700" />
<p className="text-zinc-500 font-mono text-sm mb-4">No links yet</p>
<button
onClick={() => setModal({ open: true, mode: 'create' })}
className="text-zinc-400 hover:text-zinc-100 transition-colors text-sm underline"
>
Create your first link
</button>
</div>
) : (
<div className="flex flex-col gap-3">
{links.map((link) => (
<div
key={link.id}
className="bg-zinc-900 border border-zinc-800 p-4 md:p-5 flex flex-col md:flex-row md:items-center gap-3"
>
{/* Link info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-emerald-400 font-mono text-sm truncate">
{baseUrl}/{link.code}
</span>
{link.is_custom && (
<span className="text-[10px] uppercase tracking-wider bg-zinc-800 text-zinc-400 px-1.5 py-0.5 border border-zinc-700">
Custom
</span>
)}
{link.requires_auth && (
<span className="text-[10px] uppercase tracking-wider bg-amber-950/50 text-amber-400 px-1.5 py-0.5 border border-amber-900/50 flex items-center gap-0.5">
<MdLock size={10} /> Protected
</span>
)}
</div>
<p className="text-zinc-500 text-xs font-mono truncate">{link.long_url}</p>
<p className="text-zinc-700 text-xs mt-1">
{link.click_count} click{link.click_count !== 1 ? 's' : ''} ·{' '}
{new Date(link.created_at).toLocaleDateString()}
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-1.5 shrink-0">
<button
onClick={() => handleCopy(link.code, link.id)}
className="p-2 text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800 transition-colors"
title="Copy short URL"
>
{copiedId === link.id ? <MdCheck size={16} /> : <MdContentCopy size={16} />}
</button>
<a
href={link.long_url}
target="_blank"
rel="noopener noreferrer"
className="p-2 text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800 transition-colors"
title="Open long URL"
>
<MdOpenInNew size={16} />
</a>
<button
onClick={() => setModal({ open: true, mode: 'edit', link })}
className="p-2 text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800 transition-colors"
title="Edit"
>
<MdEdit size={16} />
</button>
<button
onClick={() => handleDelete(link.id)}
className="p-2 text-zinc-500 hover:text-red-400 hover:bg-zinc-800 transition-colors"
title="Delete"
>
<MdDelete size={16} />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Modal */}
{modal.open && (
<LinkModal
mode={modal.mode}
link={modal.link}
onClose={() => setModal({ open: false, mode: 'create' })}
onSaved={fetchLinks}
/>
)}
</div>
);
}
// ---------- 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<string, any> = {};
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-zinc-900 border border-zinc-800 w-full max-w-md">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800">
<h2 className="text-sm font-bold uppercase tracking-widest text-zinc-100">
{mode === 'create' ? 'New Link' : 'Edit Link'}
</h2>
<button onClick={onClose} className="text-zinc-500 hover:text-zinc-200 transition-colors">
<MdClose size={20} />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-6 flex flex-col gap-4">
<div>
<label className="block text-zinc-500 text-xs uppercase tracking-wider mb-1.5">
Long URL
</label>
<input
type="url"
placeholder="https://..."
value={longUrl}
onChange={(e) => 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
/>
</div>
<div>
<label className="block text-zinc-500 text-xs uppercase tracking-wider mb-1.5">
Short Code <span className="text-zinc-700">(optional)</span>
</label>
<input
type="text"
placeholder="my-custom-code"
value={code}
onChange={(e) => 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}
/>
</div>
{/* Requires Auth Toggle */}
<label className="flex items-center gap-3 cursor-pointer group">
<div
className={`w-9 h-5 rounded-full relative transition-colors ${
requiresAuth ? 'bg-amber-500' : 'bg-zinc-700'
}`}
onClick={() => setRequiresAuth(!requiresAuth)}
>
<div
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
requiresAuth ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</div>
<span className="text-zinc-400 text-xs uppercase tracking-wider flex items-center gap-1.5 group-hover:text-zinc-200 transition-colors">
<MdLock size={14} /> Require authentication
</span>
</label>
{/* Auth credentials */}
{requiresAuth && (
<div className="flex flex-col gap-3 pl-4 border-l-2 border-amber-900/50">
<label className="flex items-center gap-2 cursor-pointer group text-xs">
<input
type="checkbox"
checked={useOwnCredentials}
onChange={(e) => setUseOwnCredentials(e.target.checked)}
className="w-3.5 h-3.5"
/>
<span className="text-zinc-500 group-hover:text-zinc-300 transition-colors">
Use my account credentials
</span>
</label>
{useOwnCredentials ? (
<p className="text-zinc-600 text-xs">
Visitors will need to enter your account username and password to access this link.
</p>
) : (
<>
<div>
<label className="block text-zinc-500 text-xs uppercase tracking-wider mb-1.5">
Access Username
</label>
<input
type="text"
placeholder="visitor_username"
value={accessUsername}
onChange={(e) => setAccessUsername(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={requiresAuth && !useOwnCredentials}
/>
</div>
<div>
<label className="block text-zinc-500 text-xs uppercase tracking-wider mb-1.5">
Access Password{' '}
{mode === 'edit' && (
<span className="text-zinc-700">(leave blank to keep current)</span>
)}
</label>
<input
type="password"
placeholder="••••••••"
value={accessPassword}
onChange={(e) => setAccessPassword(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={mode === 'create' && requiresAuth && !useOwnCredentials}
/>
</div>
</>
)}
</div>
)}
{error && (
<div className="text-xs font-mono uppercase tracking-wide border-l-2 pl-3 py-1 border-red-500 text-red-400">
{error}
</div>
)}
<div className="flex gap-3 mt-2">
<button
type="submit"
disabled={saving}
className="flex-1 bg-zinc-200 text-zinc-950 font-bold uppercase tracking-widest py-3 text-sm hover:bg-white transition-colors disabled:opacity-50"
>
{saving ? 'Saving...' : mode === 'create' ? 'Create' : 'Save'}
</button>
<button
type="button"
onClick={onClose}
className="px-6 py-3 bg-zinc-800 text-zinc-400 font-bold uppercase tracking-widest text-sm hover:bg-zinc-700 hover:text-zinc-200 transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -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 (
<div className="flex flex-col items-center justify-center min-h-screen p-4 pt-20">
<div className="w-full max-w-sm">
<h1 className="text-2xl font-bold tracking-widest text-zinc-100 uppercase mb-8 text-center">
Login
</h1>
<div className="bg-zinc-900 border border-zinc-800 p-6 md:p-8">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
type="text"
placeholder="Username"
value={username}
onChange={(e) => 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
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => 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
/>
<button
type="submit"
disabled={loading}
className="w-full bg-zinc-200 text-zinc-950 font-bold uppercase tracking-widest py-3 hover:bg-white transition-colors disabled:opacity-50"
>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
{error && (
<div className="mt-4 text-xs font-mono uppercase tracking-wide border-l-2 pl-3 py-1 border-red-500 text-red-400">
{error}
</div>
)}
<p className="mt-6 text-center text-zinc-600 text-sm">
No account?{' '}
<Link to="/register" className="text-zinc-400 hover:text-zinc-100 transition-colors">
Register
</Link>
</p>
</div>
</div>
</div>
);
}

View File

@@ -1,29 +1,31 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import axios from 'axios'; import { MdErrorOutline, MdLock } from 'react-icons/md';
import { MdErrorOutline } from "react-icons/md"; import api from '../lib/api';
export default function Redirect() { export default function Redirect() {
const { code } = useParams(); const { code } = useParams();
const [error, setError] = useState(false); 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(() => { useEffect(() => {
const fetchUrl = async () => { const fetchUrl = async () => {
if (!code) return; if (!code) return;
const backendUrl = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8080';
try { try {
const response = await axios.get( const response = await api.get(`/reduce/${code}`);
`${backendUrl}/reduce/${code}` if (response.data.requires_auth) {
); setNeedsAuth(true);
if (response.status === 200 && response.data.lurl) { } else if (response.data.lurl) {
window.location.replace(response.data.lurl); window.location.replace(response.data.lurl);
} else { } else {
setError(true); setError(true);
} }
} catch (err) { } catch {
console.error("Redirect error:", err);
setError(true); setError(true);
} }
}; };
@@ -31,32 +33,101 @@ export default function Redirect() {
fetchUrl(); fetchUrl();
}, [code]); }, [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) { if (error) {
return ( return (
<div className="flex items-center justify-center min-h-screen p-4"> <div className="flex items-center justify-center min-h-screen p-4">
<div className="bg-zinc-900 border border-zinc-800 p-8 max-w-md w-full text-center"> <div className="bg-zinc-900 border border-zinc-800 p-8 max-w-md w-full text-center">
<div className="flex justify-center mb-6"> <div className="flex justify-center mb-6">
<MdErrorOutline className="text-red-500 text-5xl" /> <MdErrorOutline className="text-red-500 text-5xl" />
</div>
<h1 className="text-xl font-bold mb-4 text-zinc-100 uppercase tracking-widest">404 Not Found</h1>
<p className="text-zinc-500 font-mono text-sm mb-8">
Link invalid or expired.
</p>
<a href="/" className="inline-block w-full">
<span className="block bg-zinc-200 text-zinc-900 font-bold uppercase tracking-widest py-3 hover:bg-white transition-colors border border-transparent">
Go Home
</span>
</a>
</div> </div>
<h1 className="text-xl font-bold mb-4 text-zinc-100 uppercase tracking-widest">
404 Not Found
</h1>
<p className="text-zinc-500 font-mono text-sm mb-8">Link invalid or expired.</p>
<a href="/" className="inline-block w-full">
<span className="block bg-zinc-200 text-zinc-900 font-bold uppercase tracking-widest py-3 hover:bg-white transition-colors border border-transparent">
Go Home
</span>
</a>
</div> </div>
</div>
);
}
if (needsAuth) {
return (
<div className="flex items-center justify-center min-h-screen p-4">
<div className="bg-zinc-900 border border-zinc-800 p-8 max-w-sm w-full">
<div className="flex justify-center mb-6">
<div className="bg-amber-950/30 border border-amber-900/50 rounded-full p-3">
<MdLock className="text-amber-400 text-2xl" />
</div>
</div>
<h1 className="text-lg font-bold mb-1 text-zinc-100 uppercase tracking-widest text-center">
Protected Link
</h1>
<p className="text-zinc-600 font-mono text-xs mb-6 text-center">
Enter credentials to continue
</p>
<form onSubmit={handleVerify} className="flex flex-col gap-4">
<input
type="text"
placeholder="Username"
value={username}
onChange={(e) => 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
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => 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
/>
<button
type="submit"
disabled={verifying}
className="w-full bg-zinc-200 text-zinc-950 font-bold uppercase tracking-widest py-3 hover:bg-white transition-colors disabled:opacity-50"
>
{verifying ? 'Verifying...' : 'Continue'}
</button>
</form>
{authError && (
<div className="mt-4 text-xs font-mono uppercase tracking-wide border-l-2 pl-3 py-1 border-red-500 text-red-400">
{authError}
</div>
)}
</div>
</div>
); );
} }
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen"> <div className="flex flex-col items-center justify-center min-h-screen">
<div className="font-mono text-zinc-400 text-sm tracking-widest animate-pulse"> <div className="font-mono text-zinc-400 text-sm tracking-widest animate-pulse">
Redirecting... Redirecting...
</div> </div>
</div> </div>
); );
} }

View File

@@ -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 (
<div className="flex flex-col items-center justify-center min-h-screen p-4 pt-20">
<div className="w-full max-w-sm">
<h1 className="text-2xl font-bold tracking-widest text-zinc-100 uppercase mb-8 text-center">
Register
</h1>
<div className="bg-zinc-900 border border-zinc-800 p-6 md:p-8">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<input
type="text"
placeholder="Username (332 chars)"
value={username}
onChange={(e) => 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}
/>
<input
type="password"
placeholder="Password (min 6 chars)"
value={password}
onChange={(e) => 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}
/>
<input
type="password"
placeholder="Confirm password"
value={confirm}
onChange={(e) => 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}
/>
<button
type="submit"
disabled={loading}
className="w-full bg-zinc-200 text-zinc-950 font-bold uppercase tracking-widest py-3 hover:bg-white transition-colors disabled:opacity-50"
>
{loading ? 'Creating account...' : 'Register'}
</button>
</form>
{error && (
<div className="mt-4 text-xs font-mono uppercase tracking-wide border-l-2 pl-3 py-1 border-red-500 text-red-400">
{error}
</div>
)}
<p className="mt-6 text-center text-zinc-600 text-sm">
Already have an account?{' '}
<Link to="/login" className="text-zinc-400 hover:text-zinc-100 transition-colors">
Login
</Link>
</p>
</div>
</div>
</div>
);
}