mirror of
https://github.com/arkorty/Reduce.git
synced 2026-03-17 16:41:42 +00:00
302 lines
8.1 KiB
Go
302 lines
8.1 KiB
Go
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"`
|
||
}
|
||
|
||
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(),
|
||
}
|
||
|
||
// Protected links use the creator's account credentials
|
||
if r.RequiresAuth {
|
||
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
|
||
}
|
||
|
||
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 {
|
||
// Check if user is authenticated and is the link owner
|
||
if userID, ok := c.Get("user_id").(uint); ok {
|
||
if link.UserID != nil && *link.UserID == userID {
|
||
// Auto-authorize link owner
|
||
db.Model(&link).UpdateColumn("click_count", gorm.Expr("click_count + 1"))
|
||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||
"lurl": link.LongURL,
|
||
"requires_auth": false,
|
||
"auto_authorized": true,
|
||
})
|
||
}
|
||
}
|
||
|
||
// User not logged in or not the owner - require manual auth
|
||
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")
|
||
}
|
||
|
||
// Verify against the link owner's account password
|
||
var user User
|
||
if db.Where("username = ?", link.AccessUsername).First(&user).Error != nil {
|
||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to verify credentials")
|
||
}
|
||
|
||
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []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"`
|
||
}
|
||
|
||
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 {
|
||
// Enable protection with owner's credentials
|
||
var user User
|
||
if err := db.First(&user, uid).Error; err != nil {
|
||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to load user")
|
||
}
|
||
link.RequiresAuth = true
|
||
link.AccessUsername = user.Username
|
||
link.AccessPassword = user.Password // Already hashed
|
||
} else {
|
||
// Disable protection
|
||
link.RequiresAuth = false
|
||
link.AccessUsername = ""
|
||
link.AccessPassword = ""
|
||
}
|
||
}
|
||
|
||
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"})
|
||
}
|