To gitea and beyond, let's go(-yco)

This commit is contained in:
2025-11-10 19:12:09 +01:00
parent 8f6133392d
commit 71a031342b
245 changed files with 83994 additions and 0 deletions

292
internal/handlers/common.go Normal file
View File

@@ -0,0 +1,292 @@
package handlers
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"goyco/internal/database"
"goyco/internal/dto"
"goyco/internal/middleware"
"goyco/internal/services"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
)
type CommonResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
type PaginationData struct {
Count int `json:"count"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
type VoteCookieData struct {
Type database.VoteType `json:"type"`
Timestamp int64 `json:"timestamp"`
}
func sendResponse(w http.ResponseWriter, statusCode int, success bool, message string, data any, errMsg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
response := CommonResponse{
Success: success,
Message: message,
Data: data,
Error: errMsg,
}
json.NewEncoder(w).Encode(response)
}
func SendSuccessResponse(w http.ResponseWriter, message string, data any) {
sendResponse(w, http.StatusOK, true, message, data, "")
}
func SendCreatedResponse(w http.ResponseWriter, message string, data any) {
sendResponse(w, http.StatusCreated, true, message, data, "")
}
func SendErrorResponse(w http.ResponseWriter, message string, statusCode int) {
sendResponse(w, statusCode, false, "", nil, message)
}
func DecodeJSONRequest(w http.ResponseWriter, r *http.Request, req any) bool {
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
SendErrorResponse(w, "Invalid request body", http.StatusBadRequest)
return false
}
return true
}
func GetClientIP(r *http.Request) string {
return middleware.GetSecureClientIP(r)
}
const (
CookieMaxAgeDays = 30
SecondsPerDay = 86400
DefaultPaginationLimit = 20
DefaultPaginationOffset = 0
)
func SetVoteCookie(w http.ResponseWriter, r *http.Request, postID uint, voteType database.VoteType) {
cookieName := fmt.Sprintf("vote_%d", postID)
cookieValue := fmt.Sprintf("%s:%d", voteType, time.Now().Unix())
cookie := &http.Cookie{
Name: cookieName,
Value: cookieValue,
Path: "/",
MaxAge: SecondsPerDay * CookieMaxAgeDays,
HttpOnly: true,
Secure: IsHTTPS(r),
SameSite: http.SameSiteLaxMode,
}
http.SetCookie(w, cookie)
}
func GetVoteCookie(r *http.Request, postID uint) string {
cookieName := fmt.Sprintf("vote_%d", postID)
cookie, err := r.Cookie(cookieName)
if err != nil {
return ""
}
return cookie.Value
}
func ClearVoteCookie(w http.ResponseWriter, postID uint) {
cookieName := fmt.Sprintf("vote_%d", postID)
cookie := &http.Cookie{
Name: cookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
}
http.SetCookie(w, cookie)
}
func IsHTTPS(r *http.Request) bool {
if r.TLS != nil {
return true
}
if proto := r.Header.Get("X-Forwarded-Proto"); proto == "https" {
return true
}
if proto := r.Header.Get("X-Forwarded-Ssl"); proto == "on" {
return true
}
if proto := r.Header.Get("X-Forwarded-Scheme"); proto == "https" {
return true
}
return false
}
func SanitizeUser(user *database.User) dto.SanitizedUserDTO {
if user == nil {
return dto.SanitizedUserDTO{}
}
return dto.ToSanitizedUserDTO(user)
}
func SanitizeUsers(users []database.User) []dto.SanitizedUserDTO {
return dto.ToSanitizedUserDTOs(users)
}
func parsePagination(r *http.Request) (limit, offset int) {
limit = DefaultPaginationLimit
offset = DefaultPaginationOffset
limitStr := r.URL.Query().Get("limit")
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
}
offsetStr := r.URL.Query().Get("offset")
if offsetStr != "" {
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
offset = o
}
}
return limit, offset
}
func ValidateRedirectURL(redirectURL string) string {
redirectURL = strings.TrimSpace(redirectURL)
if redirectURL == "" || len(redirectURL) > 512 {
return ""
}
if !strings.HasPrefix(redirectURL, "/") || strings.HasPrefix(redirectURL, "//") {
return ""
}
parsed, err := url.Parse(redirectURL)
if err != nil || parsed.Scheme != "" || parsed.Host != "" || parsed.User != nil || parsed.Path == "" {
return ""
}
path := parsed.EscapedPath()
if path == "" {
path = parsed.Path
}
validated := path
if parsed.RawQuery != "" {
validated += "?" + parsed.RawQuery
}
if parsed.Fragment != "" {
validated += "#" + parsed.Fragment
}
return validated
}
func ParseUintParam(w http.ResponseWriter, r *http.Request, paramName, entityName string) (uint, bool) {
str := chi.URLParam(r, paramName)
if str == "" {
SendErrorResponse(w, entityName+" ID is required", http.StatusBadRequest)
return 0, false
}
id, err := strconv.ParseUint(str, 10, 32)
if err != nil {
SendErrorResponse(w, "Invalid "+entityName+" ID", http.StatusBadRequest)
return 0, false
}
return uint(id), true
}
func RequireAuth(w http.ResponseWriter, r *http.Request) (uint, bool) {
userID := middleware.GetUserIDFromContext(r.Context())
if userID == 0 {
SendErrorResponse(w, "Authentication required", http.StatusUnauthorized)
return 0, false
}
return userID, true
}
func NewVoteContext(r *http.Request) services.VoteContext {
return services.VoteContext{
UserID: middleware.GetUserIDFromContext(r.Context()),
IPAddress: GetClientIP(r),
UserAgent: r.UserAgent(),
}
}
func HandleRepoError(w http.ResponseWriter, err error, entityName string) bool {
if err == nil {
return true
}
if errors.Is(err, gorm.ErrRecordNotFound) {
SendErrorResponse(w, entityName+" not found", http.StatusNotFound)
} else {
SendErrorResponse(w, "Failed to retrieve "+entityName, http.StatusInternalServerError)
}
return false
}
var AuthErrorMapping = []struct {
err error
msg string
code int
}{
{services.ErrInvalidCredentials, "Invalid username or password", http.StatusUnauthorized},
{services.ErrEmailNotVerified, "Please confirm your email before logging in", http.StatusForbidden},
{services.ErrAccountLocked, "Your account has been locked. Please contact us for assistance.", http.StatusForbidden},
{services.ErrUsernameTaken, "Username is already taken", http.StatusConflict},
{services.ErrEmailTaken, "Email is already registered", http.StatusConflict},
{services.ErrInvalidEmail, "Invalid email address", http.StatusBadRequest},
{services.ErrPasswordTooShort, "Password must be at least 8 characters", http.StatusBadRequest},
{services.ErrInvalidVerificationToken, "Invalid or expired verification token", http.StatusBadRequest},
{services.ErrRefreshTokenExpired, "Refresh token has expired", http.StatusUnauthorized},
{services.ErrRefreshTokenInvalid, "Invalid refresh token", http.StatusUnauthorized},
{services.ErrInvalidDeletionToken, "This deletion link is invalid or has expired.", http.StatusBadRequest},
{services.ErrDeletionRequestNotFound, "Deletion request not found", http.StatusBadRequest},
{services.ErrUserNotFound, "User not found", http.StatusNotFound},
{services.ErrEmailSenderUnavailable, "Email service is unavailable. Please try again later.", http.StatusServiceUnavailable},
}
func HandleServiceError(w http.ResponseWriter, err error, defaultMsg string, defaultCode int) bool {
if err == nil {
return true
}
for _, mapping := range AuthErrorMapping {
if err == mapping.err || errors.Is(err, mapping.err) {
SendErrorResponse(w, mapping.msg, mapping.code)
return false
}
}
errMsg := err.Error()
for _, mapping := range AuthErrorMapping {
if mapping.err.Error() == errMsg {
SendErrorResponse(w, mapping.msg, mapping.code)
return false
}
}
SendErrorResponse(w, defaultMsg, defaultCode)
return false
}