mirror of
https://github.com/arkorty/Reduce.git
synced 2026-03-17 16:41:42 +00:00
feat: version 2
This commit is contained in:
8
backend/.gitignore
vendored
8
backend/.gitignore
vendored
@@ -7,5 +7,9 @@ pkg/
|
||||
*.test
|
||||
*.prof
|
||||
.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
|
||||
|
||||
require (
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
||||
github.com/jinzhu/gorm v1.9.16
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/labstack/echo v3.3.10+incompatible
|
||||
golang.org/x/crypto v0.22.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
@@ -17,7 +19,6 @@ require (
|
||||
github.com/mattn/go-sqlite3 v1.14.0 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
golang.org/x/crypto v0.22.0 // indirect
|
||||
golang.org/x/net v0.24.0 // indirect
|
||||
golang.org/x/sys v0.19.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
|
||||
@@ -17,6 +17,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M=
|
||||
github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg=
|
||||
github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
|
||||
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
|
||||
|
||||
import (
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"time"
|
||||
"os"
|
||||
"errors"
|
||||
"strconv"
|
||||
"github.com/jinzhu/gorm"
|
||||
_ "github.com/jinzhu/gorm/dialects/sqlite"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/labstack/echo"
|
||||
"github.com/labstack/echo/middleware"
|
||||
)
|
||||
|
||||
func codegen(length int) string {
|
||||
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
bytes := make([]byte, length)
|
||||
for i := range bytes {
|
||||
bytes[i] = charset[rand.Intn(len(charset))]
|
||||
}
|
||||
return string(bytes)
|
||||
}
|
||||
|
||||
func shortenURL(c echo.Context) error {
|
||||
// Define a struct for binding the request body
|
||||
type RequestBody struct {
|
||||
LURL string `json:"lurl"`
|
||||
BaseURL string `json:"base_url"` // Expect base URL in the request
|
||||
}
|
||||
|
||||
// Bind request body to the RequestBody struct
|
||||
reqBody := new(RequestBody)
|
||||
if err := c.Bind(reqBody); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate the base URL
|
||||
if reqBody.BaseURL == "" {
|
||||
// Fallback to BASE_URL environment variable
|
||||
reqBody.BaseURL = os.Getenv("BASE_URL")
|
||||
if reqBody.BaseURL == "" {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Base URL is not configured")
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the long URL already exists in the database
|
||||
var existingURL CodeURLMap
|
||||
if err := db.Where("lurl = ?", reqBody.LURL).First(&existingURL).Error; err == nil {
|
||||
// If the long URL exists, return the existing short URL
|
||||
return c.JSON(http.StatusOK, map[string]string{
|
||||
"surl": reqBody.BaseURL + "/" + existingURL.Code,
|
||||
})
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// If there's an error other than record not found, return an error
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to check existing URL")
|
||||
}
|
||||
|
||||
// Generate a unique code
|
||||
codelen := 6
|
||||
if os.Getenv("CODE_LENGTH") != "" && os.Getenv("CODE_LENGTH") != "0" {
|
||||
t, err := strconv.Atoi(os.Getenv("CODE_LENGTH"))
|
||||
if err == nil {
|
||||
codelen = t
|
||||
}
|
||||
}
|
||||
code := codegen(codelen)
|
||||
|
||||
// Create URL record
|
||||
url := &CodeURLMap{
|
||||
Code: code,
|
||||
LURL: reqBody.LURL,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Save URL record to the database
|
||||
if err := db.Create(url).Error; err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create URL record")
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusCreated, map[string]string{
|
||||
"surl": reqBody.BaseURL + "/" + code,
|
||||
})
|
||||
}
|
||||
|
||||
func fetchLURL(c echo.Context) error {
|
||||
code := c.Param("code")
|
||||
var url CodeURLMap
|
||||
if err := db.Where("code = ?", code).First(&url).Error; err != nil {
|
||||
log.Println("Error retrieving URL:", err)
|
||||
return echo.NewHTTPError(http.StatusNotFound, "URL not found")
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]string{"lurl": url.LURL})
|
||||
}
|
||||
|
||||
|
||||
func main() {
|
||||
godotenv.Load()
|
||||
defer db.Close()
|
||||
|
||||
e := echo.New()
|
||||
@@ -107,15 +21,33 @@ func main() {
|
||||
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete},
|
||||
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization},
|
||||
}))
|
||||
|
||||
// Routes
|
||||
// Health
|
||||
e.GET("/", func(c echo.Context) error {
|
||||
return c.String(http.StatusOK, "Backend is running alright.\n")
|
||||
})
|
||||
|
||||
e.POST("/reduce/shorten", shortenURL)
|
||||
e.GET("/reduce/:code", fetchLURL)
|
||||
// Auth
|
||||
e.POST("/auth/register", register)
|
||||
e.POST("/auth/login", login)
|
||||
e.GET("/auth/me", getMe, JWTMiddleware)
|
||||
|
||||
e.Logger.Fatal(e.Start(":8080"))
|
||||
// Public link routes (optional auth for shorten)
|
||||
e.POST("/reduce/shorten", shortenURL, OptionalJWTMiddleware)
|
||||
e.GET("/reduce/:code", fetchLURL)
|
||||
e.POST("/reduce/:code/verify", verifyAndRedirect)
|
||||
|
||||
// Authenticated link management
|
||||
links := e.Group("/links", JWTMiddleware)
|
||||
links.GET("", listLinks)
|
||||
links.PUT("/:id", updateLink)
|
||||
links.DELETE("/:id", deleteLink)
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
e.Logger.Fatal(e.Start(":" + port))
|
||||
}
|
||||
|
||||
@@ -2,17 +2,35 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
_ "github.com/jinzhu/gorm/dialects/sqlite"
|
||||
|
||||
"os"
|
||||
)
|
||||
|
||||
type CodeURLMap struct {
|
||||
Code string `gorm:"primary_key" json:"code"`
|
||||
LURL string `json:"lurl" gorm:"column:lurl"`
|
||||
// User represents a registered account
|
||||
type User struct {
|
||||
ID uint `gorm:"primary_key" json:"id"`
|
||||
Username string `gorm:"unique_index;not null;size:32" json:"username"`
|
||||
Password string `gorm:"not null" json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Link represents a shortened URL
|
||||
type Link struct {
|
||||
ID uint `gorm:"primary_key" json:"id"`
|
||||
UserID *uint `gorm:"index" json:"user_id"`
|
||||
Code string `gorm:"unique_index;not null;size:32" json:"code"`
|
||||
LongURL string `gorm:"not null;column:long_url" json:"long_url"`
|
||||
IsCustom bool `gorm:"default:false" json:"is_custom"`
|
||||
RequiresAuth bool `gorm:"default:false" json:"requires_auth"`
|
||||
AccessUsername string `gorm:"column:access_username;size:64" json:"access_username,omitempty"`
|
||||
AccessPassword string `gorm:"column:access_password" json:"-"`
|
||||
ClickCount int `gorm:"default:0" json:"click_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
var db *gorm.DB
|
||||
@@ -29,6 +47,8 @@ func init() {
|
||||
panic(fmt.Sprintf("Failed to connect to database: %v", err))
|
||||
}
|
||||
|
||||
// Auto-migrate database
|
||||
db.AutoMigrate(&CodeURLMap{})
|
||||
db.Exec("PRAGMA foreign_keys = ON")
|
||||
db.Exec("PRAGMA journal_mode = WAL")
|
||||
|
||||
db.AutoMigrate(&User{}, &Link{})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user