Compare commits

...
5 Commits
41 changed files with 1504 additions and 432 deletions

No files matched your search

-35
View File
@@ -1,35 +0,0 @@
# Server Configuration
export ADDR=":8080"
export EXTERNAL_URL="localhost:8080"
export FRONTEND_URL="http://localhost:5173"
export ENV="development"
export ALLOWED_ORIGINS="https://*,http://*" # TODO: Change to the actual allowed origins on production
# Database Configuration
export DB_ADDR="postgres://postgres:postgres@localhost:5432/lk_tmpl?sslmode=disable"
export DB_MAX_OPEN_CONNS="30"
export DB_MAX_IDLE_CONNS="30"
export DB_MAX_IDLE_TIME="15m"
# Redis Configuration
export REDIS_ADDR="localhost:6379"
export REDIS_PASSWORD=""
export REDIS_DB="0"
export REDIS_ENABLED="false"
# Authentication
export BASIC_AUTH_USERNAME="admin"
export BASIC_AUTH_PASSWORD="admin"
export AUTH_TOKEN_SECRET="your-secret-key-here"
export AUTH_TOKEN_EXP="24h"
export AUTH_TOKEN_ISS="lkapi"
export AUTH_TOKEN_AUD="lkapi"
# Email Configuration
export FROM_EMAIL="[email protected]"
export SENDGRID_API_KEY="your-sendgrid-api-key"
# Rate Limiter
export RATE_LIMITER_REQS_COUNT="20"
export RATE_LIMITER_TIME_FRAME="1m"
export RATE_LIMITER_ENABLED="true"
+1
View File
@@ -1 +1,2 @@
.envrc
/bin/
+1 -1
View File
@@ -27,7 +27,7 @@ migrate-down:
.PHONY: seed
seed:
@go run cmd/migrate/seed/main.go
@DB_ADDR=$(DB_ADDR) go run cmd/migrate/seed/main.go
.PHONY: gen-docs
gen-docs:
+1 -1
View File
@@ -1 +1 @@
exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit stLine truncated
exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit stLine truncated
BIN
View File
Binary file not shown.
+27 -11
View File
@@ -48,7 +48,8 @@ type config struct {
}
type mailConfig struct {
exp time.Duration
inviteExp time.Duration
passwordRefreshExp time.Duration
fromEmail string
sendGrid sendGridConfig
}
@@ -66,6 +67,7 @@ type basicConfig struct {
type tokenConfig struct {
secret string
exp time.Duration
refreshExp time.Duration
iss string
aud string
}
@@ -89,6 +91,8 @@ type dbConfig struct {
maxIdleTime time.Duration
}
// This `mount` function is responsible for setting up the routing for your API
// using the Chi router. Here's a breakdown of what it does:
func (api *api) mount() http.Handler {
r := chi.NewRouter()
// *CORS
@@ -106,7 +110,7 @@ func (api *api) mount() http.Handler {
AllowCredentials: false,
MaxAge: 300,
}))
r.Use(api.RateLimiterMiddleware)
r.Use(api.rateLimiterMiddleware)
r.Use(middleware.Timeout(60 * time.Second))
@@ -135,26 +139,32 @@ func (api *api) mount() http.Handler {
r.Route("/users", func(r chi.Router) {
r.Put("/activate/{token}", api.activateUserHandler)
r.Put("/reset-password/{token}", api.resetPasswordHandler)
r.Route("/{id}", func(r chi.Router) {
r.Route("/me", func(r chi.Router) {
r.Use(api.AuthTokenMiddleware)
r.Get("/", api.getUserHandler)
r.Patch("/", api.checkProfileOwnership(api.updateUserHandler))
r.Patch("/password", api.checkProfileOwnership(api.updatePasswordHandler))
r.Put(("/follow"), api.followUserHandler)
r.Put(("/unfollow"), api.unfollowUserHandler)
})
r.Group(func(r chi.Router) {
r.Use(api.AuthTokenMiddleware)
r.Get("/feed", api.getUserFeedHandler)
})
})
r.Route("/authentication", func(r chi.Router) {
r.Post("/user", api.registerUserHandler)
r.Post("/register", api.registerUserHandler)
r.Post("/token", api.createTokenHandler)
r.Post("/forgot-password", api.forgotPasswordHandler)
r.Post("/refresh", api.refreshTokenHandler)
})
r.Route("/admin", func(r chi.Router) {
r.Use(api.AuthTokenMiddleware)
r.Route("/users", func(r chi.Router) {
r.Get("/", api.protectAdminRoutes("moderator", api.getUsersHandler))
r.Route("/{id}", func(r chi.Router) {
//r.Get("/", api.protectAdminRoutes("moderator", api.getUserByIdHandler))
})
})
})
})
@@ -204,6 +214,12 @@ func (api *api) run(mux http.Handler) error {
return err
}
if userStore, ok := api.store.Users.(*store.UserStore); ok {
if err := userStore.Close(); err != nil {
api.logger.Errorw("error closing userstore", err)
}
}
api.logger.Infow("server has been shutdown")
return nil
+1 -1
View File
@@ -28,7 +28,7 @@ func TestRateLimiterMiddleware(t *testing.T) {
mockIP := "192.168.1.1"
marginOfError := 2
for i := 0; i < cfg.rateLimiter.RequestsPerTimeFrame+marginOfError; i++ {
for i := range cfg.rateLimiter.RequestsPerTimeFrame + marginOfError {
req, err := http.NewRequest("GET", ts.URL+"/v1/health", nil)
if err != nil {
t.Fatalf("failed to create request: %v", err)
+183 -8
View File
@@ -5,7 +5,10 @@ import (
"encoding/hex"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/FernandoVideira/LK_API_Temp/internal/mailer"
@@ -27,6 +30,15 @@ type UserWithToken struct {
Token string `json:"token"`
}
type ForgotPasswordPayload struct {
Email string `json:"email" validate:"required,email,max=255"`
}
type AuthResponse struct {
Token string `json:"token"`
RefreshToken string `json:"refresh_token"`
}
// RegisterUser godoc
//
// @Summary Register a new User
@@ -38,7 +50,7 @@ type UserWithToken struct {
// @Success 201 {object} UserWithToken "User Registered"
// @Failure 400 {object} error "User payload error"
// @Failure 500 {object} error "Internal Server Error"
// @Router /authentication/user [post]
// @Router /authentication/register [post]
func (api *api) registerUserHandler(w http.ResponseWriter, r *http.Request) {
var payload RegisterUserPayload
if err := readJSON(w, r, &payload); err != nil {
@@ -75,7 +87,7 @@ func (api *api) registerUserHandler(w http.ResponseWriter, r *http.Request) {
hashedToken := hex.EncodeToString(hash[:])
// Store the user
err := api.store.Users.CreateAndInvite(ctx, user, hashedToken, api.config.mail.exp)
err := api.store.Users.CreateAndInvite(ctx, user, hashedToken, api.config.mail.inviteExp)
if err != nil {
switch {
case errors.Is(err, store.ErrDuplicateEmail):
@@ -178,6 +190,160 @@ func (api *api) createTokenHandler(w http.ResponseWriter, r *http.Request) {
return
}
//* send the token back to the user
authResponse, err := api.createTokens(user)
if err != nil {
api.internalServerError(w, r, err)
return
}
if err := api.jsonResponse(w, http.StatusCreated, authResponse); err != nil {
api.internalServerError(w, r, err)
return
}
}
// RefreshTokenHandler godoc
//
// @Summary Refresh a token
// @Description Refresh a token for the user
// @Tags authentication
// @Accept json
// @Produce json
// @Param Authorization header string true "Refresh Token"
// @Success 201 {string} string "Token Created"
// @Failure 400 {object} error
// @Failure 401 {object} error
// @Failure 500 {object} error
// @Router /authentication/refresh [post]
func (api *api) refreshTokenHandler(w http.ResponseWriter, r *http.Request) {
//* parse the refresh token from the request
refreshHeader := r.Header.Get("Authorization")
if refreshHeader == "" {
api.unauthorizedError(w, r, errors.New("refresh token is required"))
return
}
parts := strings.Split(refreshHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
api.unauthorizedError(w, r, fmt.Errorf("invalid Authorization header"))
return
}
refreshToken := parts[1]
token, err := api.authenticator.ValidateRefreshToken(refreshToken)
if err != nil {
api.unauthorizedError(w, r, err)
return
}
claims := token.Claims.(jwt.MapClaims)
//* get the user id from the token
userID, err := strconv.ParseInt(fmt.Sprintf("%.f", claims["sub"]), 10, 64)
if err != nil {
api.unauthorizedError(w, r, fmt.Errorf("invalid token"))
return
}
//* get the user from the database
user, err := api.store.Users.GetByID(r.Context(), userID)
if err != nil {
api.unauthorizedError(w, r, err)
return
}
authResponse, err := api.createTokens(user)
if err != nil {
api.unauthorizedError(w, r, err)
return
}
if err := api.jsonResponse(w, http.StatusCreated, authResponse); err != nil {
api.internalServerError(w, r, err)
return
}
}
// ResetPasswordHandler godoc
//
// @Summary Forgot Password
// @Description Forgot Password
// @Tags authentication
// @Accept json
// @Produce json
// @Param payload body ForgotPasswordPayload true "User Credentials"
// @Success 201 {string} string "Token Created"
// @Failure 400 {object} error
// @Failure 401 {object} error
// @Failure 500 {object} error
// @Router /authentication/forgot-password [post]
func (api *api) forgotPasswordHandler(w http.ResponseWriter, r *http.Request) {
var payload ForgotPasswordPayload
if err := readJSON(w, r, &payload); err != nil {
api.badRequestError(w, r, err)
return
}
if err := Validate.Struct(payload); err != nil {
api.badRequestError(w, r, err)
return
}
user, err := api.store.Users.GetByEmail(r.Context(), payload.Email)
if err != nil {
switch err {
case store.ErrNotFound:
api.badRequestError(w, r, err)
default:
api.internalServerError(w, r, err)
}
}
plainToken := uuid.New().String()
hash := sha256.Sum256([]byte(plainToken))
hashedToken := hex.EncodeToString(hash[:])
//*Store The Request
err = api.store.Users.PasswordResetRequest(r.Context(), user.ID, hashedToken, api.config.mail.passwordRefreshExp)
if err != nil {
api.internalServerError(w, r, err)
return
}
updateURL := fmt.Sprintf("%s/reset-password/%s", api.config.frontEndURL, plainToken)
isProdEnv := api.config.env == "production"
vars := struct {
Username string
ServiceName string
UpdateURL string
Email string
CurrentYear string
}{
Username: user.Username,
ServiceName: "LK_API_Temp",
UpdateURL: updateURL,
Email: user.Email,
CurrentYear: time.Now().Format("2006"),
}
status, err := api.mailer.Send(mailer.ForgotPasswordTemplate, user.Username, user.Email, vars, !isProdEnv)
if err != nil {
api.internalServerError(w, r, err)
return
}
api.logger.Infow("email sent", "status code", status)
if err := api.jsonResponse(w, http.StatusCreated, plainToken); err != nil {
api.internalServerError(w, r, err)
return
}
}
func (api *api) createTokens(user *store.User) (AuthResponse, error) {
claims := &jwt.MapClaims{
"sub": user.ID,
"exp": time.Now().Add(api.config.auth.token.exp).Unix(),
@@ -190,14 +356,23 @@ func (api *api) createTokenHandler(w http.ResponseWriter, r *http.Request) {
//* generate a new token -> add the claims
token, err := api.authenticator.GenerateToken(claims)
if err != nil {
api.internalServerError(w, r, err)
return
return AuthResponse{}, err
}
//* send the token back to the user
refreshClaims := &jwt.MapClaims{
"sub": user.ID,
"exp": time.Now().Add(api.config.auth.token.refreshExp).Unix(),
}
if err := api.jsonResponse(w, http.StatusCreated, token); err != nil {
api.internalServerError(w, r, err)
return
log.Println(time.Now().Add(api.config.auth.token.refreshExp).Unix())
refreshToken, err := api.authenticator.GenerateRefreshToken(refreshClaims)
if err != nil {
return AuthResponse{}, err
}
return AuthResponse{
Token: token,
RefreshToken: refreshToken,
}, nil
}
-2
View File
@@ -1,7 +1,6 @@
package main
import (
"log"
"net/http"
"github.com/FernandoVideira/LK_API_Temp/internal/store"
@@ -48,7 +47,6 @@ func (api *api) getUserFeedHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user := getUserFromContext(r)
log.Println(user)
feed, err := api.store.Posts.GetUserFeed(ctx, user.ID, fq)
if err != nil {
api.internalServerError(w, r, err)
+7 -2
View File
@@ -56,7 +56,8 @@ func main() {
},
env: env.GetString("ENV", "development"),
mail: mailConfig{
exp: time.Hour * 24 * 3,
inviteExp: time.Hour * 24 * 3,
passwordRefreshExp: time.Hour * 24 * 7,
fromEmail: env.GetString("FROM_EMAIL", ""),
sendGrid: sendGridConfig{
apiKey: env.GetString("SENDGRID_API_KEY", ""),
@@ -70,6 +71,7 @@ func main() {
token: tokenConfig{
secret: env.GetString("AUTH_TOKEN_SECRET", "secret"),
exp: env.GetDuration("AUTH_TOKEN_EXP", "24h"),
refreshExp: time.Hour * 24 * 7,
iss: env.GetString("AUTH_TOKEN_ISS", "lkapi"),
aud: env.GetString("AUTH_TOKEN_AUD", "lkapi"),
},
@@ -111,7 +113,10 @@ func main() {
cfg.rateLimiter.TimeFrame,
)
store := store.NewStorage(db)
store, err := store.NewStorage(db)
if err != nil {
logger.Fatal(err)
}
cacheStorage := cache.NewRedisStorage(redisDb)
mailer := mailer.NewSendGrid(cfg.mail.sendGrid.apiKey, cfg.mail.fromEmail)
+34 -14
View File
@@ -104,6 +104,7 @@ func (api *api) postsContextMiddleware(next http.Handler) http.Handler {
api.badRequestError(w, r, err)
return
}
ctx := r.Context()
post, err := api.store.Posts.GetByID(ctx, id)
@@ -111,17 +112,31 @@ func (api *api) postsContextMiddleware(next http.Handler) http.Handler {
switch {
case errors.Is(err, store.ErrNotFound):
api.notFoundError(w, r, err)
return
default:
api.internalServerError(w, r, err)
}
return
}
}
ctx = context.WithValue(ctx, postCtx, post)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (api *api) rateLimiterMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if api.config.rateLimiter.Enabled {
if allow, retryAfter := api.rateLimiter.Allow(r.RemoteAddr); !allow {
api.rateLimitExceededError(w, r, retryAfter.String())
return
}
}
next.ServeHTTP(w, r)
})
}
func (api *api) checkPostOwnership(requiredRole string, next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := getUserFromContext(r)
@@ -161,6 +176,24 @@ func (api *api) checkProfileOwnership(next http.HandlerFunc) http.HandlerFunc {
})
}
func (api *api) protectAdminRoutes(requiredRole string, next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := getUserFromContext(r)
allowed, err := api.checkRolePrecedence(r.Context(), user, requiredRole)
if err != nil {
api.internalServerError(w, r, err)
return
}
if !allowed {
api.forbiddenError(w, r, fmt.Errorf("forbidden"))
return
}
next.ServeHTTP(w, r)
})
}
func (api *api) checkRolePrecedence(ctx context.Context, user *store.User, roleName string) (bool, error) {
role, err := api.store.Roles.GetByName(ctx, roleName)
if err != nil {
@@ -193,16 +226,3 @@ func (api *api) getUser(ctx context.Context, id int64) (*store.User, error) {
return user, nil
}
func (api *api) RateLimiterMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if api.config.rateLimiter.Enabled {
if allow, retryAfter := api.rateLimiter.Allow(r.RemoteAddr); !allow {
api.rateLimitExceededError(w, r, retryAfter.String())
return
}
}
next.ServeHTTP(w, r)
})
}
+84 -2
View File
@@ -3,7 +3,6 @@ package main
import (
"context"
"errors"
"log"
"net/http"
"strconv"
@@ -12,8 +11,39 @@ import (
)
type userKey string
type roleKey string
const userContextKey userKey = "user"
const roleContextKey roleKey = "role"
// GetUser godoc
//
// @Summary Fetches all users
// @Description Fetches all users
// @Tags admin
// @Accept json
// @Produce json
// @Success 200 {object} store.User
// @Failure 400 {object} error
// @Failure 404 {object} error
// @Failure 500 {object} error
// @Security ApiKeyAuth
// @Router /admin/users [get]
func (api *api) getUsersHandler(w http.ResponseWriter, r *http.Request) {
user := getUserFromContext(r)
if user == nil {
api.unauthorizedError(w, r, errors.New("user not found"))
return
}
users, err := api.store.Users.GetAll(r.Context(), user.ID, user.Role.Level)
if err != nil {
api.internalServerError(w, r, err)
return
}
api.jsonResponse(w, http.StatusOK, users)
}
// GetUser godoc
//
@@ -54,6 +84,57 @@ func (api *api) getUserHandler(w http.ResponseWriter, r *http.Request) {
}
}
type resetPasswordPayload struct {
NewPassword string `json:"new_password" validate:"required,password"`
NewPasswordConfirmation string `json:"new_password_confirmation" validate:"required,eqfield=NewPassword"`
}
// ResetPassword godoc
//
// @Summary Reset user password
// @Description Reset user password with verification
// @Tags users
// @Accept json
// @Param payload body resetPasswordPayload true "Reset Password"
// @Param token path string true "Reset Password Token"
// @Success 204 {string} string "Password reset successfully"
// @Failure 400 {object} error "Invalid request"
// @Failure 401 {object} error "Unauthorized"
// @Failure 500 {object} error "Internal server error"
// @Security ApiKeyAuth
// @Router /users/reset-password/{token} [put]
func (api *api) resetPasswordHandler(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
var payload resetPasswordPayload
if err := readJSON(w, r, &payload); err != nil {
api.badRequestError(w, r, err)
return
}
if err := Validate.Struct(payload); err != nil {
api.badRequestError(w, r, err)
return
}
err := api.store.Users.ResetPassword(r.Context(), token, payload.NewPassword)
if err != nil {
switch err {
case store.ErrNotFound:
api.notFoundError(w, r, err)
return
default:
api.internalServerError(w, r, err)
return
}
}
w.WriteHeader(http.StatusNoContent)
if err := api.jsonResponse(w, http.StatusNoContent, ""); err != nil {
api.internalServerError(w, r, err)
}
}
type updateUserPayload struct {
FirstName *string `json:"first_name" validate:"omitempty,max=100"`
LastName *string `json:"last_name" validate:"omitempty,max=100"`
@@ -78,7 +159,6 @@ type updateUserPayload struct {
// @Router /users/{id} [patch]
func (api *api) updateUserHandler(w http.ResponseWriter, r *http.Request) {
user := getUserFromContext(r)
log.Println(user)
var payload updateUserPayload
if err := readJSON(w, r, &payload); err != nil {
@@ -103,7 +183,9 @@ func (api *api) updateUser(ctx context.Context, user *store.User) error {
return err
}
if api.config.redisCfg.enabled {
api.cacheStore.Users.Delete(ctx, user.ID)
}
return nil
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS password_reset_requests;
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS password_reset_requests (
token bytea PRIMARY KEY,
user_id bigint NOT NULL,
expiry TIMESTAMP(0)
WITH
TIME ZONE NOT NULL
);
+5 -1
View File
@@ -16,6 +16,10 @@ func main() {
}
defer conn.Close()
store := store.NewStorage(conn)
store, err := store.NewStorage(conn)
if err != nil {
log.Fatalf("could not create storage: %v", err)
}
db.Seed(store, conn)
}
+254 -42
View File
@@ -24,6 +24,178 @@ const docTemplate = `{
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/admin/users": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Fetches all users",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "Fetches all users",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/store.User"
}
},
"400": {
"description": "Bad Request",
"schema": {}
},
"404": {
"description": "Not Found",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/authentication/forgot-password": {
"post": {
"description": "Forgot Password",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"authentication"
],
"summary": "Forgot Password",
"parameters": [
{
"description": "User Credentials",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/main.ForgotPasswordPayload"
}
}
],
"responses": {
"201": {
"description": "Token Created",
"schema": {
"type": "string"
}
},
"400": {
"description": "Bad Request",
"schema": {}
},
"401": {
"description": "Unauthorized",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/authentication/refresh": {
"post": {
"description": "Refresh a token for the user",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"authentication"
],
"summary": "Refresh a token",
"parameters": [
{
"type": "string",
"description": "Refresh Token",
"name": "Authorization",
"in": "header",
"required": true
}
],
"responses": {
"201": {
"description": "Token Created",
"schema": {
"type": "string"
}
},
"400": {
"description": "Bad Request",
"schema": {}
},
"401": {
"description": "Unauthorized",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/authentication/register": {
"post": {
"description": "Register a new User",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"authentication"
],
"summary": "Register a new User",
"parameters": [
{
"description": "User Credentials",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/main.RegisterUserPayload"
}
}
],
"responses": {
"201": {
"description": "User Registered",
"schema": {
"$ref": "#/definitions/main.UserWithToken"
}
},
"400": {
"description": "User payload error",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/authentication/token": {
"post": {
"description": "Create a new token for the user",
@@ -70,48 +242,6 @@ const docTemplate = `{
}
}
},
"/authentication/user": {
"post": {
"description": "Register a new User",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"authentication"
],
"summary": "Register a new User",
"parameters": [
{
"description": "User Credentials",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/main.RegisterUserPayload"
}
}
],
"responses": {
"201": {
"description": "User Registered",
"schema": {
"$ref": "#/definitions/main.UserWithToken"
}
},
"400": {
"description": "User payload error",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/health": {
"get": {
"description": "Health Check",
@@ -509,6 +639,61 @@ const docTemplate = `{
}
}
},
"/users/reset-password/{token}": {
"put": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Reset user password with verification",
"consumes": [
"application/json"
],
"tags": [
"users"
],
"summary": "Reset user password",
"parameters": [
{
"description": "Reset Password",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/main.resetPasswordPayload"
}
},
{
"type": "string",
"description": "Reset Password Token",
"name": "token",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "Password reset successfully",
"schema": {
"type": "string"
}
},
"400": {
"description": "Invalid request",
"schema": {}
},
"401": {
"description": "Unauthorized",
"schema": {}
},
"500": {
"description": "Internal server error",
"schema": {}
}
}
}
},
"/users/{id}": {
"get": {
"security": [
@@ -795,6 +980,18 @@ const docTemplate = `{
}
}
},
"main.ForgotPasswordPayload": {
"type": "object",
"required": [
"email"
],
"properties": {
"email": {
"type": "string",
"maxLength": 255
}
}
},
"main.RegisterUserPayload": {
"type": "object",
"required": [
@@ -877,6 +1074,21 @@ const docTemplate = `{
}
}
},
"main.resetPasswordPayload": {
"type": "object",
"required": [
"new_password",
"new_password_confirmation"
],
"properties": {
"new_password": {
"type": "string"
},
"new_password_confirmation": {
"type": "string"
}
}
},
"main.updateUserPasswordPayload": {
"type": "object",
"required": [
+254 -42
View File
@@ -16,6 +16,178 @@
},
"basePath": "/v1",
"paths": {
"/admin/users": {
"get": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Fetches all users",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "Fetches all users",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/store.User"
}
},
"400": {
"description": "Bad Request",
"schema": {}
},
"404": {
"description": "Not Found",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/authentication/forgot-password": {
"post": {
"description": "Forgot Password",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"authentication"
],
"summary": "Forgot Password",
"parameters": [
{
"description": "User Credentials",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/main.ForgotPasswordPayload"
}
}
],
"responses": {
"201": {
"description": "Token Created",
"schema": {
"type": "string"
}
},
"400": {
"description": "Bad Request",
"schema": {}
},
"401": {
"description": "Unauthorized",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/authentication/refresh": {
"post": {
"description": "Refresh a token for the user",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"authentication"
],
"summary": "Refresh a token",
"parameters": [
{
"type": "string",
"description": "Refresh Token",
"name": "Authorization",
"in": "header",
"required": true
}
],
"responses": {
"201": {
"description": "Token Created",
"schema": {
"type": "string"
}
},
"400": {
"description": "Bad Request",
"schema": {}
},
"401": {
"description": "Unauthorized",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/authentication/register": {
"post": {
"description": "Register a new User",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"authentication"
],
"summary": "Register a new User",
"parameters": [
{
"description": "User Credentials",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/main.RegisterUserPayload"
}
}
],
"responses": {
"201": {
"description": "User Registered",
"schema": {
"$ref": "#/definitions/main.UserWithToken"
}
},
"400": {
"description": "User payload error",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/authentication/token": {
"post": {
"description": "Create a new token for the user",
@@ -62,48 +234,6 @@
}
}
},
"/authentication/user": {
"post": {
"description": "Register a new User",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"authentication"
],
"summary": "Register a new User",
"parameters": [
{
"description": "User Credentials",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/main.RegisterUserPayload"
}
}
],
"responses": {
"201": {
"description": "User Registered",
"schema": {
"$ref": "#/definitions/main.UserWithToken"
}
},
"400": {
"description": "User payload error",
"schema": {}
},
"500": {
"description": "Internal Server Error",
"schema": {}
}
}
}
},
"/health": {
"get": {
"description": "Health Check",
@@ -501,6 +631,61 @@
}
}
},
"/users/reset-password/{token}": {
"put": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Reset user password with verification",
"consumes": [
"application/json"
],
"tags": [
"users"
],
"summary": "Reset user password",
"parameters": [
{
"description": "Reset Password",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/main.resetPasswordPayload"
}
},
{
"type": "string",
"description": "Reset Password Token",
"name": "token",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "Password reset successfully",
"schema": {
"type": "string"
}
},
"400": {
"description": "Invalid request",
"schema": {}
},
"401": {
"description": "Unauthorized",
"schema": {}
},
"500": {
"description": "Internal server error",
"schema": {}
}
}
}
},
"/users/{id}": {
"get": {
"security": [
@@ -787,6 +972,18 @@
}
}
},
"main.ForgotPasswordPayload": {
"type": "object",
"required": [
"email"
],
"properties": {
"email": {
"type": "string",
"maxLength": 255
}
}
},
"main.RegisterUserPayload": {
"type": "object",
"required": [
@@ -869,6 +1066,21 @@
}
}
},
"main.resetPasswordPayload": {
"type": "object",
"required": [
"new_password",
"new_password_confirmation"
],
"properties": {
"new_password": {
"type": "string"
},
"new_password_confirmation": {
"type": "string"
}
}
},
"main.updateUserPasswordPayload": {
"type": "object",
"required": [
+169 -28
View File
@@ -29,6 +29,14 @@ definitions:
- email
- password
type: object
main.ForgotPasswordPayload:
properties:
email:
maxLength: 255
type: string
required:
- email
type: object
main.RegisterUserPayload:
properties:
email:
@@ -86,6 +94,16 @@ definitions:
username:
type: string
type: object
main.resetPasswordPayload:
properties:
new_password:
type: string
new_password_confirmation:
type: string
required:
- new_password
- new_password_confirmation
type: object
main.updateUserPasswordPayload:
properties:
current_password:
@@ -230,6 +248,121 @@ info:
termsOfService: http://swagger.io/terms/
title: LK API Template
paths:
/admin/users:
get:
consumes:
- application/json
description: Fetches all users
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/store.User'
"400":
description: Bad Request
schema: {}
"404":
description: Not Found
schema: {}
"500":
description: Internal Server Error
schema: {}
security:
- ApiKeyAuth: []
summary: Fetches all users
tags:
- admin
/authentication/forgot-password:
post:
consumes:
- application/json
description: Forgot Password
parameters:
- description: User Credentials
in: body
name: payload
required: true
schema:
$ref: '#/definitions/main.ForgotPasswordPayload'
produces:
- application/json
responses:
"201":
description: Token Created
schema:
type: string
"400":
description: Bad Request
schema: {}
"401":
description: Unauthorized
schema: {}
"500":
description: Internal Server Error
schema: {}
summary: Forgot Password
tags:
- authentication
/authentication/refresh:
post:
consumes:
- application/json
description: Refresh a token for the user
parameters:
- description: Refresh Token
in: header
name: Authorization
required: true
type: string
produces:
- application/json
responses:
"201":
description: Token Created
schema:
type: string
"400":
description: Bad Request
schema: {}
"401":
description: Unauthorized
schema: {}
"500":
description: Internal Server Error
schema: {}
summary: Refresh a token
tags:
- authentication
/authentication/register:
post:
consumes:
- application/json
description: Register a new User
parameters:
- description: User Credentials
in: body
name: payload
required: true
schema:
$ref: '#/definitions/main.RegisterUserPayload'
produces:
- application/json
responses:
"201":
description: User Registered
schema:
$ref: '#/definitions/main.UserWithToken'
"400":
description: User payload error
schema: {}
"500":
description: Internal Server Error
schema: {}
summary: Register a new User
tags:
- authentication
/authentication/token:
post:
consumes:
@@ -261,34 +394,6 @@ paths:
summary: Create a new token
tags:
- authentication
/authentication/user:
post:
consumes:
- application/json
description: Register a new User
parameters:
- description: User Credentials
in: body
name: payload
required: true
schema:
$ref: '#/definitions/main.RegisterUserPayload'
produces:
- application/json
responses:
"201":
description: User Registered
schema:
$ref: '#/definitions/main.UserWithToken'
"400":
description: User payload error
schema: {}
"500":
description: Internal Server Error
schema: {}
summary: Register a new User
tags:
- authentication
/health:
get:
description: Health Check
@@ -717,6 +822,42 @@ paths:
summary: Fetches the user feed
tags:
- feed
/users/reset-password/{token}:
put:
consumes:
- application/json
description: Reset user password with verification
parameters:
- description: Reset Password
in: body
name: payload
required: true
schema:
$ref: '#/definitions/main.resetPasswordPayload'
- description: Reset Password Token
in: path
name: token
required: true
type: string
responses:
"204":
description: Password reset successfully
schema:
type: string
"400":
description: Invalid request
schema: {}
"401":
description: Unauthorized
schema: {}
"500":
description: Internal server error
schema: {}
security:
- ApiKeyAuth: []
summary: Reset user password
tags:
- users
securityDefinitions:
ApiKeyAuth:
in: header
+3
View File
@@ -3,6 +3,7 @@ module github.com/FernandoVideira/LK_API_Temp
go 1.23.4
require (
github.com/Masterminds/squirrel v1.5.4
github.com/go-chi/chi/v5 v5.2.0
github.com/go-chi/cors v1.2.1
github.com/go-playground/validator/v10 v10.24.0
@@ -31,6 +32,8 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
+7
View File
@@ -1,5 +1,7 @@
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -42,6 +44,10 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
@@ -64,6 +70,7 @@ github.com/sendgrid/sendgrid-go v3.16.0+incompatible h1:i8eE6IMkiCy7vusSdacHHSBU
github.com/sendgrid/sendgrid-go v3.16.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw=
+2
View File
@@ -4,5 +4,7 @@ import "github.com/golang-jwt/jwt/v5"
type Authenticator interface {
GenerateToken(jwt.Claims) (string, error)
GenerateRefreshToken(jwt.Claims) (string, error)
ValidateToken(string) (*jwt.Token, error)
ValidateRefreshToken(string) (*jwt.Token, error)
}
+24
View File
@@ -31,6 +31,17 @@ func (j *JWTAuthenticator) GenerateToken(claims jwt.Claims) (string, error) {
return tokenString, nil
}
func (j *JWTAuthenticator) GenerateRefreshToken(claims jwt.Claims) (string, error) {
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
refreshTokenString, err := refreshToken.SignedString([]byte(j.secret))
if err != nil {
return "", err
}
return refreshTokenString, nil
}
func (j *JWTAuthenticator) ValidateToken(tokenString string) (*jwt.Token, error) {
return jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
@@ -45,3 +56,16 @@ func (j *JWTAuthenticator) ValidateToken(tokenString string) (*jwt.Token, error)
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}),
)
}
func (j *JWTAuthenticator) ValidateRefreshToken(tokenString string) (*jwt.Token, error) {
return jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return []byte(j.secret), nil
},
jwt.WithExpirationRequired(),
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}),
)
}
+12
View File
@@ -24,8 +24,20 @@ func (a *TestAuthenticator) GenerateToken(claims jwt.Claims) (string, error) {
return token.SignedString([]byte(testToken))
}
func (a *TestAuthenticator) GenerateRefreshToken(claims jwt.Claims) (string, error) {
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, testClaims)
return refreshToken.SignedString([]byte(testToken))
}
func (a *TestAuthenticator) ValidateToken(token string) (*jwt.Token, error) {
return jwt.Parse(token, func(token *jwt.Token) (any, error) {
return []byte(testToken), nil
})
}
func (a *TestAuthenticator) ValidateRefreshToken(token string) (*jwt.Token, error) {
return jwt.Parse(token, func(token *jwt.Token) (any, error) {
return []byte(testToken), nil
})
}
+1
View File
@@ -6,6 +6,7 @@ const (
FromName = "LK API"
maxRetries = 3
UserWelcomeTemplate = "user_activation.tmpl"
ForgotPasswordTemplate = "forgot_password.tmpl"
)
//go:embed "templates"
@@ -0,0 +1,27 @@
{{define "subject"}} Reset Your Password - {{.ServiceName}} {{end}}
{{define "body"}}
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<p>Hello {{.Username}},</p>
<p>We received a request to reset your password. If you didn't make this request, you can ignore this email.</p>
<p>To reset your password, click on the link below:</p>
<p><a href="{{.UpdateURL}}">{{.UpdateURL}}</a></p>
<p>If you want to reset your password manually, copy and paste the link above into your browser.</p>
<p>If you have any questions or need assistance, please contact our support team.</p>
<p>Thanks,</p>
<p>The {{.ServiceName}} Team</p>
</body>
</html>
{{end}}
-3
View File
@@ -1,7 +1,6 @@
package ratelimiter
import (
"log"
"sync"
"time"
)
@@ -26,8 +25,6 @@ func (rl *FixedWindowLimiter) Allow(ip string) (bool, time.Duration) {
count, exists := rl.clients[ip]
rl.RUnlock()
log.Println(count, exists)
if !exists || count < rl.limit {
rl.Lock()
if !exists {
+15
View File
@@ -28,6 +28,11 @@ func (m *MockUserStore) Activate(ctx context.Context, token string) error {
return args.Error(0)
}
func (m *MockUserStore) GetAll(ctx context.Context, userID int64, roleLevel int64) ([]*User, error) {
args := m.Called(userID, roleLevel)
return nil, args.Error(1)
}
func (m *MockUserStore) GetByID(ctx context.Context, id int64) (*User, error) {
args := m.Called(id)
return nil, args.Error(1)
@@ -48,6 +53,16 @@ func (m *MockUserStore) CreateAndInvite(ctx context.Context, user *User, token s
return args.Error(0)
}
func (m *MockUserStore) PasswordResetRequest(ctx context.Context, userID int64, token string, expiration time.Duration) error {
args := m.Called(ctx, userID, token, expiration)
return args.Error(0)
}
func (m *MockUserStore) ResetPassword(ctx context.Context, token string, password string) error {
args := m.Called(ctx, token, password)
return args.Error(0)
}
func (m *MockUserStore) Delete(ctx context.Context, id int64) error {
args := m.Called(id)
return args.Error(0)
+16 -4
View File
@@ -14,21 +14,33 @@ type Role struct {
type RoleStore struct {
db *sql.DB
getByNameStmt *sql.Stmt
}
func (s *RoleStore) GetByName(ctx context.Context, name string) (*Role, error) {
query := `
func NewRoleStore(db *sql.DB) (*RoleStore, error) {
getByNameStmt, err := db.PrepareContext(context.Background(), `
SELECT id, name, level, description
FROM roles
WHERE name = $1
`
`)
if err != nil {
return nil, err
}
return &RoleStore{
db: db,
getByNameStmt: getByNameStmt,
}, nil
}
func (s *RoleStore) GetByName(ctx context.Context, name string) (*Role, error) {
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
role := &Role{}
err := s.db.QueryRowContext(ctx, query, name).Scan(
err := s.getByNameStmt.QueryRowContext(ctx, name).Scan(
&role.ID,
&role.Name,
&role.Level,
+27 -4
View File
@@ -27,10 +27,13 @@ type Storage struct {
Users interface {
Create(context.Context, *sql.Tx, *User) error
Activate(context.Context, string) error
GetAll(context.Context, int64, int64) ([]*User, error)
GetByID(context.Context, int64) (*User, error)
GetByEmail(context.Context, string) (*User, error)
Update(context.Context, *User) error
CreateAndInvite(context.Context, *User, string, time.Duration) error
PasswordResetRequest(context.Context, int64, string, time.Duration) error
ResetPassword(context.Context, string, string) error
Delete(context.Context, int64) error
DeleteUser(context.Context, int64) error
}
@@ -47,14 +50,20 @@ type Storage struct {
}
}
func NewStorage(db *sql.DB) Storage {
func NewStorage(db *sql.DB) (Storage, error) {
userStore, roleStore, err := initStores(db)
if err != nil {
return Storage{}, err
}
return Storage{
Posts: &PostStore{db},
Users: &UserStore{db},
Users: userStore,
Comments: &CommentStore{db},
Followers: &FollowerStore{db},
Roles: &RoleStore{db},
}
Roles: roleStore,
}, nil
}
func withTx(db *sql.DB, ctx context.Context, f func(*sql.Tx) error) error {
@@ -70,3 +79,17 @@ func withTx(db *sql.DB, ctx context.Context, f func(*sql.Tx) error) error {
return tx.Commit()
}
func initStores(db *sql.DB) (*UserStore, *RoleStore, error) {
userStore, err := NewUserStore(db)
if err != nil {
return &UserStore{}, &RoleStore{}, err
}
roleStore, err := NewRoleStore(db)
if err != nil {
return &UserStore{}, &RoleStore{}, err
}
return userStore, roleStore, nil
}
+341 -71
View File
@@ -8,6 +8,14 @@ import (
"time"
"golang.org/x/crypto/bcrypt"
sq "github.com/Masterminds/squirrel"
)
const (
adminLevel = 3
modLevel = 2
userLevel = 1
)
type User struct {
@@ -29,6 +37,8 @@ type password struct {
hash []byte
}
var psql = sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
func (p *password) Set(password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
@@ -42,30 +52,163 @@ func (p *password) Set(password string) error {
type UserStore struct {
db *sql.DB
getByIDStmt *sql.Stmt
getByEmailStmt *sql.Stmt
getByInvitationStmt *sql.Stmt
getUserFromPasswordTokenStmt *sql.Stmt
updateStmt *sql.Stmt
deleteStmt *sql.Stmt
inviteCreateStmt *sql.Stmt
invitationDeleteStmt *sql.Stmt
deletePasswordResetRequestStmt *sql.Stmt
passwordResetRequestCreateStmt *sql.Stmt
}
func NewUserStore(db *sql.DB) (*UserStore, error) {
store := &UserStore{
db: db,
}
var err error
store.getByIDStmt, err = db.Prepare(`
SELECT u.id, u.first_name, u.last_name, u.username, u.email, u.password, u.is_active, u.created_at, u.updated_at, roles.*
FROM users u
JOIN roles ON u.role_id = roles.id
WHERE u.id = $1 AND u.is_active = true
`)
if err != nil {
return nil, err
}
store.getByEmailStmt, err = db.Prepare(`
SELECT u.id, u.first_name, u.last_name, u.username, u.email, u.password, u.is_active, u.created_at, u.updated_at, roles.*
FROM users u
JOIN roles ON u.role_id = roles.id
WHERE u.email = $1 AND u.is_active = true
`)
if err != nil {
return nil, err
}
store.getByInvitationStmt, err = db.Prepare(`
SELECT u.id, u.first_name, u.last_name, u.username, u.email, u.created_at, u.updated_at, is_active
FROM users u
JOIN invitations i ON u.id = i.user_id
WHERE i.token = $1 AND i.expiry > $2
`)
if err != nil {
return nil, err
}
store.getUserFromPasswordTokenStmt, err = db.Prepare(`
SELECT u.id, u.first_name, u.last_name, u.username, u.email, u.password, u.is_active, u.created_at, u.updated_at
FROM users u
JOIN password_reset_requests pr ON u.id = pr.user_id
WHERE u.id = $1 AND pr.expiry > $2
`)
if err != nil {
return nil, err
}
store.updateStmt, err = db.Prepare(`
UPDATE users
SET first_name = $1, last_name = $2, username = $3, email = $4, is_active = $5, updated_at = $6
WHERE id = $7
`)
if err != nil {
return nil, err
}
store.deleteStmt, err = db.Prepare(`
DELETE FROM users WHERE id = $1
`)
if err != nil {
return nil, err
}
store.invitationDeleteStmt, err = db.Prepare(`
DELETE FROM invitations WHERE user_id = $1
`)
if err != nil {
return nil, err
}
store.deletePasswordResetRequestStmt, err = db.Prepare(`
DELETE FROM password_reset_requests WHERE user_id = $1
`)
if err != nil {
return nil, err
}
store.inviteCreateStmt, err = db.Prepare(`
INSERT INTO invitations (user_id, token, expiry) VALUES ($1, $2, $3)
`)
if err != nil {
return nil, err
}
store.passwordResetRequestCreateStmt, err = db.Prepare(`
INSERT INTO password_reset_requests (user_id, token, expiry) VALUES ($1, $2, $3)
`)
if err != nil {
return nil, err
}
return store, nil
}
func (s *UserStore) Close() error {
var errs []error
if err := s.getByIDStmt.Close(); err != nil {
errs = append(errs, err)
}
if err := s.getByEmailStmt.Close(); err != nil {
errs = append(errs, err)
}
if err := s.getByInvitationStmt.Close(); err != nil {
errs = append(errs, err)
}
if err := s.updateStmt.Close(); err != nil {
errs = append(errs, err)
}
if err := s.deleteStmt.Close(); err != nil {
errs = append(errs, err)
}
if err := s.invitationDeleteStmt.Close(); err != nil {
errs = append(errs, err)
}
if len(errs) > 0 {
return errs[0]
}
return nil
}
func (s *UserStore) Create(ctx context.Context, tx *sql.Tx, user *User) error {
query := `
INSERT INTO users (first_name, last_name, username, email, password, role_id)
VALUES ($1, $2, $3, $4, $5, (SELECT id FROM roles WHERE name = $6)) RETURNING id, created_at, updated_at
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
role := user.Role.Name
if role == "" {
role = "user"
}
err := tx.QueryRowContext(
query, args, err := psql.Insert("users").
Columns("first_name", "last_name", "username", "email", "password", "role_id").
Values(user.FirstName, user.LastName, user.Username, user.Email, user.Password.hash, sq.Expr("(SELECT id FROM roles WHERE name = ?)", role)).
Suffix("RETURNING id, created_at, updated_at").ToSql()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
err = tx.QueryRowContext(
ctx,
query,
user.FirstName,
user.LastName,
user.Username,
user.Email,
user.Password.hash,
role,
args...,
).Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt)
if err != nil {
switch {
@@ -81,28 +224,76 @@ func (s *UserStore) Create(ctx context.Context, tx *sql.Tx, user *User) error {
return nil
}
func (s *UserStore) GetAll(ctx context.Context, userID int64, roleLevel int64) ([]*User, error) {
var whereConditions []sq.Sqlizer
whereConditions = append(whereConditions, sq.Eq{"u.is_active": true})
if roleLevel == modLevel {
whereConditions = append(whereConditions, sq.Or{
sq.Lt{"roles.level": roleLevel},
sq.Eq{"u.id": userID},
})
}
query, args, err := psql.Select("u.id", "u.first_name", "u.last_name", "u.username", "u.email", "u.is_active", "u.created_at", "u.updated_at", "roles.*").
From("users u").Join("roles ON u.role_id = roles.id").Where(sq.And(
whereConditions,
)).OrderBy("u.created_at DESC").
ToSql()
if err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
users := make([]*User, 0)
for rows.Next() {
user := &User{}
err := rows.Scan(
&user.ID,
&user.FirstName,
&user.LastName,
&user.Username,
&user.Email,
&user.IsActive,
&user.CreatedAt,
&user.UpdatedAt,
&user.RoleID,
&user.Role.Name,
&user.Role.Level,
&user.Role.Description,
)
if err != nil {
return nil, err
}
users = append(users, user)
}
return users, nil
}
func (s *UserStore) GetByID(ctx context.Context, id int64) (*User, error) {
query := `
SELECT users.id, first_name, last_name, username, email, password, created_at, updated_at, roles.*
FROM users
JOIN roles ON (users.role_id = roles.id)
WHERE users.id = $1
AND is_active = true
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
user := &User{}
err := s.db.QueryRowContext(ctx, query, id).Scan(
err := s.getByIDStmt.QueryRowContext(ctx, id).Scan(
&user.ID,
&user.FirstName,
&user.LastName,
&user.Username,
&user.Email,
&user.Password.hash,
&user.IsActive,
&user.CreatedAt,
&user.UpdatedAt,
&user.Role.ID,
&user.RoleID,
&user.Role.Name,
&user.Role.Level,
&user.Role.Description,
@@ -115,34 +306,26 @@ func (s *UserStore) GetByID(ctx context.Context, id int64) (*User, error) {
return nil, err
}
}
return user, nil
}
func (s *UserStore) GetByEmail(ctx context.Context, email string) (*User, error) {
query := `
SELECT users.id, first_name, last_name, username, email, password, created_at, updated_at, roles.*
FROM users
JOIN roles ON (users.role_id = roles.id)
WHERE email = $1
AND is_active = true
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
user := &User{}
err := s.db.QueryRowContext(ctx, query, email).Scan(
err := s.getByEmailStmt.QueryRowContext(ctx, email).Scan(
&user.ID,
&user.FirstName,
&user.LastName,
&user.Username,
&user.Email,
&user.Password.hash,
&user.IsActive,
&user.CreatedAt,
&user.UpdatedAt,
&user.Role.ID,
&user.RoleID,
&user.Role.Name,
&user.Role.Level,
&user.Role.Description,
@@ -160,17 +343,11 @@ func (s *UserStore) GetByEmail(ctx context.Context, email string) (*User, error)
}
func (s *UserStore) Update(ctx context.Context, user *User) error {
query := `
UPDATE users
SET first_name = $1, last_name = $2, username = $3, email = $4, password = $5
WHERE id = $6
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := s.db.ExecContext(
_, err := s.updateStmt.ExecContext(
ctx,
query,
user.FirstName,
user.LastName,
user.Username,
@@ -199,10 +376,40 @@ func (s *UserStore) CreateAndInvite(ctx context.Context, user *User, token strin
})
}
func (s *UserStore) PasswordResetRequest(ctx context.Context, userID int64, token string, expiration time.Duration) error {
return withTx(s.db, ctx, func(tx *sql.Tx) error {
if err := s.createPasswordResetRequest(ctx, tx, userID, token, expiration); err != nil {
return err
}
return nil
})
}
func (s *UserStore) ResetPassword(ctx context.Context, token string, newPassword string) error {
return withTx(s.db, ctx, func(tx *sql.Tx) error {
user, err := s.getUserFromPaswordToken(ctx, tx, token)
if err != nil {
return err
}
user.Password.Set(newPassword)
if err := s.update(ctx, tx, user); err != nil {
return err
}
if err := s.deletePasswordResetRequest(ctx, tx, user.ID); err != nil {
return err
}
return nil
})
}
func (s *UserStore) Activate(ctx context.Context, token string) error {
return withTx(s.db, ctx, func(tx *sql.Tx) error {
// Find the user that this token belongs to
user, err := getUserFromInvitation(ctx, tx, token)
user, err := s.getUserFromInvitation(ctx, tx, token)
if err != nil {
return err
}
@@ -238,12 +445,18 @@ func (s *UserStore) Delete(ctx context.Context, id int64) error {
// * Soft delete user
func (s *UserStore) DeleteUser(ctx context.Context, id int64) error {
query := `UPDATE users SET is_active = false WHERE id = $1`
query, _, err := psql.Update("users").
Set("is_active", false).
Where(sq.Eq{"id": id}).
ToSql()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := s.db.ExecContext(ctx, query, id)
_, err = s.db.ExecContext(ctx, query, id)
if err != nil {
return err
}
@@ -255,22 +468,18 @@ func (p *password) Compare(password string) error {
return bcrypt.CompareHashAndPassword(p.hash, []byte(password))
}
func getUserFromInvitation(ctx context.Context, tx *sql.Tx, token string) (*User, error) {
query := `
SELECT u.id, u.first_name, u.last_name, u.username, u.email, u.created_at, u.updated_at, is_active
FROM users u
JOIN invitations i ON u.id = i.user_id
WHERE i.token = $1 AND i.expiry > $2
`
func (s *UserStore) getUserFromInvitation(ctx context.Context, tx *sql.Tx, token string) (*User, error) {
hashed := sha256.Sum256([]byte(token))
hashedToken := hex.EncodeToString(hashed[:])
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
stmt := tx.Stmt(s.getByInvitationStmt)
defer stmt.Close()
user := &User{}
err := tx.QueryRowContext(ctx, query, hashedToken, time.Now()).Scan(
err := stmt.QueryRowContext(ctx, hashedToken, time.Now()).Scan(
&user.ID,
&user.FirstName,
&user.LastName,
@@ -293,14 +502,13 @@ func getUserFromInvitation(ctx context.Context, tx *sql.Tx, token string) (*User
}
func (s *UserStore) createUserInvitation(ctx context.Context, tx *sql.Tx, userID int64, exp time.Duration, token string) error {
query := `
INSERT INTO invitations (user_id, token, expiry)
VALUES ($1, $2, $3)
`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := tx.ExecContext(ctx, query, userID, token, time.Now().Add(exp))
stmt := tx.Stmt(s.inviteCreateStmt)
defer stmt.Close()
_, err := stmt.ExecContext(ctx, userID, token, time.Now().Add(exp))
if err != nil {
return err
}
@@ -308,18 +516,63 @@ func (s *UserStore) createUserInvitation(ctx context.Context, tx *sql.Tx, userID
return nil
}
func (s *UserStore) update(ctx context.Context, tx *sql.Tx, user *User) error {
query := `
UPDATE users
SET first_name = $1, last_name = $2, username = $3, email = $4, is_active = $5
WHERE id = $6
`
func (s *UserStore) createPasswordResetRequest(ctx context.Context, tx *sql.Tx, userID int64, token string, expiry time.Duration) error {
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := tx.ExecContext(
stmt := tx.Stmt(s.passwordResetRequestCreateStmt)
defer stmt.Close()
_, err := stmt.ExecContext(ctx, userID, token, time.Now().Add(expiry))
if err != nil {
return err
}
return nil
}
func (s *UserStore) getUserFromPaswordToken(ctx context.Context, tx *sql.Tx, token string) (*User, error) {
hashed := sha256.Sum256([]byte(token))
hashedToken := hex.EncodeToString(hashed[:])
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
stmt := tx.Stmt(s.getUserFromPasswordTokenStmt)
defer stmt.Close()
user := &User{}
err := stmt.QueryRowContext(ctx, hashedToken, time.Now()).Scan(
&user.ID,
&user.FirstName,
&user.LastName,
&user.Username,
&user.Email,
&user.CreatedAt,
&user.UpdatedAt,
&user.IsActive,
)
if err != nil {
switch err {
case sql.ErrNoRows:
return nil, ErrNotFound
default:
return nil, err
}
}
return user, nil
}
func (s *UserStore) update(ctx context.Context, tx *sql.Tx, user *User) error {
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
stmt := tx.Stmt(s.updateStmt)
defer stmt.Close()
_, err := stmt.ExecContext(
ctx,
query,
user.FirstName,
user.LastName,
user.Username,
@@ -335,12 +588,28 @@ func (s *UserStore) update(ctx context.Context, tx *sql.Tx, user *User) error {
}
func (s *UserStore) deleteUserInvitation(ctx context.Context, tx *sql.Tx, userID int64) error {
query := `DELETE FROM invitations WHERE user_id = $1`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := tx.ExecContext(ctx, query, userID)
stmt := tx.Stmt(s.invitationDeleteStmt)
defer stmt.Close()
_, err := stmt.ExecContext(ctx, userID)
if err != nil {
return err
}
return nil
}
func (s *UserStore) deletePasswordResetRequest(ctx context.Context, tx *sql.Tx, userID int64) error {
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
stmt := tx.Stmt(s.deletePasswordResetRequestStmt)
defer stmt.Close()
_, err := stmt.ExecContext(ctx, userID)
if err != nil {
return err
}
@@ -349,12 +618,13 @@ func (s *UserStore) deleteUserInvitation(ctx context.Context, tx *sql.Tx, userID
}
func (s *UserStore) delete(ctx context.Context, tx *sql.Tx, id int64) error {
query := `DELETE FROM users WHERE id = $1`
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
defer cancel()
_, err := tx.ExecContext(ctx, query, id)
stmt := tx.Stmt(s.deleteStmt)
defer stmt.Close()
_, err := stmt.ExecContext(ctx, id)
if err != nil {
return err
}
-24
View File
@@ -1,24 +0,0 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example
-75
View File
@@ -1,75 +0,0 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
-6
View File
@@ -1,6 +0,0 @@
<template>
<div>
<NuxtRouteAnnouncer />
<NuxtWelcome />
</div>
</template>
BIN
View File
Binary file not shown.
-6
View File
@@ -1,6 +0,0 @@
// @ts-check
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt(
// Your custom configs here
)
-15
View File
@@ -1,15 +0,0 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2024-11-01',
devtools: { enabled: true },
modules: [
'@nuxt/content',
'@nuxt/eslint',
'@nuxt/fonts',
'@nuxt/icon',
'@nuxt/image',
'@nuxt/scripts',
'@nuxt/test-utils'
]
})
-26
View File
@@ -1,26 +0,0 @@
{
"name": "nuxt-app",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@nuxt/content": "3.4.0",
"@nuxt/eslint": "1.3.0",
"@nuxt/fonts": "0.11.0",
"@nuxt/icon": "1.11.0",
"@nuxt/image": "1.10.0",
"@nuxt/scripts": "0.11.5",
"@nuxt/test-utils": "3.17.2",
"@unhead/vue": "^2.0.0-rc.8",
"eslint": "^9.0.0",
"nuxt": "^3.16.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

-1
View File
@@ -1 +0,0 @@
-3
View File
@@ -1,3 +0,0 @@
{
"extends": "../.nuxt/tsconfig.server.json"
}
-4
View File
@@ -1,4 +0,0 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}