mirror of
https://github.com/arkorty/Reduce.git
synced 2026-03-18 00:47:10 +00:00
feat: version 2
This commit is contained in:
118
README.md
118
README.md
@@ -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.
|
||||||
|
|||||||
@@ -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
8
backend/.gitignore
vendored
@@ -7,5 +7,9 @@ pkg/
|
|||||||
*.test
|
*.test
|
||||||
*.prof
|
*.prof
|
||||||
.env
|
.env
|
||||||
# Local database (if applicable)
|
|
||||||
reduce.db
|
# Database
|
||||||
|
reduce.db*
|
||||||
|
|
||||||
|
# Binary
|
||||||
|
reduce
|
||||||
@@ -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
171
backend/auth.go
Normal 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 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
325
backend/handlers.go
Normal 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 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"})
|
||||||
|
}
|
||||||
116
backend/main.go
116
backend/main.go
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{})
|
||||||
}
|
}
|
||||||
|
|||||||
28
compose.yml
28
compose.yml
@@ -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
|
|
||||||
@@ -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 (
|
||||||
|
<AuthProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<Navbar />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route path="/links" element={<Links />} />
|
||||||
<Route path="/:code" element={<Redirect />} />
|
<Route path="/:code" element={<Redirect />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
56
frontend/src/components/Navbar.tsx
Normal file
56
frontend/src/components/Navbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
frontend/src/context/AuthContext.tsx
Normal file
72
frontend/src/context/AuthContext.tsx
Normal 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
15
frontend/src/lib/api.ts
Normal 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;
|
||||||
@@ -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;
|
||||||
return;
|
if (longUrl.trim() === '') return;
|
||||||
}
|
|
||||||
|
|
||||||
if (longUrl.trim() === "") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
new URL(longUrl);
|
new URL(longUrl);
|
||||||
} catch (_) {
|
} catch {
|
||||||
setStatus({ type: 'error', msg: 'Invalid URL' });
|
setStatus({ type: 'error', msg: 'Invalid URL' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseURL = window.location.origin;
|
if (!user && requiresAuth) {
|
||||||
const backendUrl = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8080';
|
setStatus({ type: 'error', msg: 'Login required for protected links' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
try {
|
||||||
const response = await axios.post(
|
const payload: Record<string, any> = {
|
||||||
`${backendUrl}/reduce/shorten`,
|
|
||||||
{
|
|
||||||
lurl: longUrl,
|
lurl: longUrl,
|
||||||
base_url: baseURL,
|
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,21 +73,26 @@ 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 & manage links
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Main Interface */}
|
{/* Main Interface */}
|
||||||
@@ -87,6 +108,60 @@ export default function Home() {
|
|||||||
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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Advanced options for logged-in users */}
|
||||||
|
{user && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<MdTune size={14} />
|
||||||
|
{showAdvanced ? 'Hide options' : 'Options'}
|
||||||
|
</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
|
<button
|
||||||
type="submit"
|
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]"
|
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]"
|
||||||
@@ -120,7 +195,11 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center bg-white p-4">
|
<div className="flex justify-center bg-white p-4">
|
||||||
<QRCode value={shortUrl} size={150} style={{ height: "auto", maxWidth: "100%", width: "100%" }} />
|
<QRCode
|
||||||
|
value={shortUrl}
|
||||||
|
size={150}
|
||||||
|
style={{ height: 'auto', maxWidth: '100%', width: '100%' }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
418
frontend/src/pages/Links.tsx
Normal file
418
frontend/src/pages/Links.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
frontend/src/pages/Login.tsx
Normal file
79
frontend/src/pages/Login.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,6 +33,25 @@ 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">
|
||||||
@@ -38,10 +59,10 @@ export default function Redirect() {
|
|||||||
<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>
|
</div>
|
||||||
<h1 className="text-xl font-bold mb-4 text-zinc-100 uppercase tracking-widest">404 Not Found</h1>
|
<h1 className="text-xl font-bold mb-4 text-zinc-100 uppercase tracking-widest">
|
||||||
<p className="text-zinc-500 font-mono text-sm mb-8">
|
404 Not Found
|
||||||
Link invalid or expired.
|
</h1>
|
||||||
</p>
|
<p className="text-zinc-500 font-mono text-sm mb-8">Link invalid or expired.</p>
|
||||||
<a href="/" className="inline-block w-full">
|
<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">
|
<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
|
Go Home
|
||||||
@@ -52,6 +73,56 @@ export default function Redirect() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
|||||||
98
frontend/src/pages/Register.tsx
Normal file
98
frontend/src/pages/Register.tsx
Normal 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 (3–32 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user