diff --git a/backend/.gitignore b/backend/.gitignore index 25f4cc8..57aa957 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,2 +1,2 @@ tmp -media +/media diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..2f43241 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,18 @@ +# Prerequisites for development + +- Installing dependencies: `go mod tidy` +- Verifying that the `.env` file is present and well configured. + +## Running migrations + +A migration cli is made available through the package: `./cmd/migrate` +At first it is needed to run: `go run ./cmd/migrate db init` to initialize the +tables needed by golang's bun. + +Then, if there are migrations needed to be applied, run: `go run ./cmd/migrate db migrate` + +Everything should be taken care of. + +## Running the server + +The server can be ran as easily as: `go run .` diff --git a/backend/api/blogs/blogs.go b/backend/api/blogs/blogs.go new file mode 100644 index 0000000..65a8dfe --- /dev/null +++ b/backend/api/blogs/blogs.go @@ -0,0 +1,58 @@ +package blogs + +import ( + "context" + "fmt" + "net/http" + + core "fr.latosa-escrima/api/core" +) + +func HandleBlog(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + blog_uuid := r.PathValue("uuid") + + var blog core.Blog + _, err := core.DB.NewSelect(). + Model(&blog). + Where("blog_id = ?", blog_uuid). + Relation("Author"). + ScanAndCount(context.Background()) + if err != nil { + core.JSONError{ + Status: core.Error, + Message: err.Error(), + }.Respond(w, http.StatusNotAcceptable) + return + } + + core.JSONSuccess{ + Status: core.Success, + Message: "Status OK", + Data: blog, + }.Respond(w, http.StatusOK) + return +} + +func HandleGetBlogs(w http.ResponseWriter, r *http.Request) { + var blog []core.Blog + count, err := core.DB.NewSelect(). + Model(&blog). + Relation("Author"). + ScanAndCount(context.Background()) + if err != nil { + core.JSONError{ + Status: core.Error, + Message: err.Error(), + }.Respond(w, http.StatusNotAcceptable) + return + } + + core.JSONSuccess{ + Status: core.Success, + Message: fmt.Sprint("%d blogs objects sent", count), + Data: blog, + }.Respond(w, http.StatusOK) + return +} diff --git a/backend/api/delete_blog.go b/backend/api/blogs/delete_blog.go similarity index 100% rename from backend/api/delete_blog.go rename to backend/api/blogs/delete_blog.go diff --git a/backend/api/new_blog.go b/backend/api/blogs/new_blog.go similarity index 71% rename from backend/api/new_blog.go rename to backend/api/blogs/new_blog.go index 78d4d57..96b4916 100644 --- a/backend/api/new_blog.go +++ b/backend/api/blogs/new_blog.go @@ -1,14 +1,22 @@ -package api +package blogs import ( "context" "encoding/json" "net/http" - + "io" core "fr.latosa-escrima/api/core" ) -func HandleCreateBlog(w http.ResponseWriter, r *http.Request) { +func HandleNew(w http.ResponseWriter, r *http.Request) { + _, err := io.ReadAll(r.Body) + if err != nil { + core.JSONError{ + Status: core.Error, + Message: err.Error(), + }.Respond(w, http.StatusNoContent) + return + } var blog core.Blog if err := json.NewDecoder(r.Body).Decode(&blog); err != nil { core.JSONError{ diff --git a/backend/api/core/schemas.go b/backend/api/core/schemas.go index a2b6e35..1569ff8 100644 --- a/backend/api/core/schemas.go +++ b/backend/api/core/schemas.go @@ -27,13 +27,6 @@ func (dsn *DSN) ToString() string { return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", dsn.User, dsn.Password, dsn.Hostname, dsn.Port, dsn.DBName) } -type Role string - -const ( - AdminRole Role = "admin" - UserRole Role = "user" -) - type Status string const ( @@ -41,20 +34,35 @@ const ( Inactive Status = "Inactive" ) +type Group string + +const ( + LatosaGroup Group = "latosa" + WingTsunGroup Group = "wing-tsun" +) + +type UserAttributes struct { + Groups []Group `json:"groups"` +} + +type PermissionConditions struct { + Groups *[]Group `json:"groups,omitempty"` +} + type User struct { bun.BaseModel `bun:"table:users"` - UserID uuid.UUID `bun:"type:uuid,pk,default:gen_random_uuid()" json:"userId"` - FirstName string `bun:"firstname,notnull" json:"firstname"` - LastName string `bun:"lastname,notnull" json:"lastname"` - Email string `bun:"email,unique,notnull" json:"email"` - Password string `bun:"password,notnull" json:"password,omitempty"` - Phone string `bun:"phone,notnull" json:"phone"` - Role Role `bun:"role,notnull,default:'user'" json:"role"` - CreatedAt time.Time `bun:"created_at,default:current_timestamp" json:"createdAt"` - UpdatedAt time.Time `bun:"updated_at,default:current_timestamp" json:"updatedAt"` - Events []Event `bun:"m2m:events_to_users,join:User=Event" json:"events,omitempty"` - Articles []*Blog `bun:"rel:has-many,join:user_id=blog_id" json:"articles,omitempty"` + UserID uuid.UUID `bun:"type:uuid,pk,default:gen_random_uuid()" json:"userId"` + FirstName string `bun:"firstname,notnull" json:"firstname"` + LastName string `bun:"lastname,notnull" json:"lastname"` + Email string `bun:"email,unique,notnull" json:"email"` + Password string `bun:"password,notnull" json:"password,omitempty"` + Phone string `bun:"phone,notnull" json:"phone"` + CreatedAt time.Time `bun:"created_at,default:current_timestamp" json:"createdAt"` + UpdatedAt time.Time `bun:"updated_at,default:current_timestamp" json:"updatedAt"` + Events []Event `bun:"m2m:events_to_users,join:User=Event" json:"events,omitempty"` + Articles []*Blog `bun:"rel:has-many,join:user_id=blog_id" json:"articles,omitempty"` + Attributes UserAttributes `bun:"attributes,type:jsonb" json:"attributes"` } func (u *User) Insert(ctx context.Context) (sql.Result, error) { @@ -66,15 +74,6 @@ func (u *User) Insert(ctx context.Context) (sql.Result, error) { } func Verify(ctx context.Context, email, password string) (*User, error) { - // var user User - // query := ` - // SELECT * - // FROM users - // WHERE email = ? AND password = crypt(?, password) - // ` - // - // err := DB.NewRaw(query, email, password).Scan(ctx, user) - var user User count, err := DB.NewSelect(). Model(&user). @@ -94,6 +93,40 @@ func Verify(ctx context.Context, email, password string) (*User, error) { return &user, nil } +type Permission struct { + bun.BaseModel `bun:"table:permissions"` + ID int `bun:"id,pk,autoincrement" json:"id"` + Resource string `bun:"resource,notnull" json:"resource"` + Action string `bun:"action,notnull" json:"action"` + Conditions PermissionConditions `bun:"conditions,type:jsonb" json:"conditions"` +} + +type Role struct { + bun.BaseModel `bun:"table:roles"` + ID uuid.UUID `bun:"id,pk,type:uuid,default:gen_random_uuid()" json:"id"` + Name string `bun:"name,unique,notnull" json:"name"` +} + +type PermissionToRole struct { + bun.BaseModel `bun:"table:permissions_to_users"` + + PermissionID int `bun:"permission_id,pk"` + RoleID uuid.UUID `bun:"type:uuid,pk"` + + Permission *Permission `bun:"rel:belongs-to,join:permission_id=id"` + Role *Role `bun:"rel:belongs-to,join:role_id=id"` +} + +type UserToRole struct { + bun.BaseModel `bun:"table:users_to_roles"` + + UserID uuid.UUID `bun:"user_id,type:uuid,pk"` + RoleID uuid.UUID `bun:"type:uuid,pk"` + + User *User `bun:"rel:belongs-to,join:user_id=user_id"` + Role *Role `bun:"rel:belongs-to,join:role_id=id"` +} + type Event struct { bun.BaseModel `bun:"table:events"` @@ -188,6 +221,8 @@ func InitDatabase(dsn DSN) (*bun.DB, error) { return nil, err } db.RegisterModel((*EventToUser)(nil)) + db.RegisterModel((*PermissionToRole)(nil)) + db.RegisterModel((*UserToRole)(nil)) _, err = db.NewCreateTable().Model((*User)(nil)).IfNotExists().Exec(ctx) _, err = db.NewCreateTable().Model((*Event)(nil)).IfNotExists().Exec(ctx) _, err = db.NewCreateTable().Model((*EventToUser)(nil)).IfNotExists().Exec(ctx) @@ -195,6 +230,10 @@ func InitDatabase(dsn DSN) (*bun.DB, error) { _, err = db.NewCreateTable().Model((*WebsiteSettings)(nil)).IfNotExists().Exec(ctx) _, err = db.NewCreateTable().Model((*Media)(nil)).IfNotExists().Exec(ctx) _, err = db.NewCreateTable().Model((*Shortcode)(nil)).IfNotExists().Exec(ctx) + _, err = db.NewCreateTable().Model((*Role)(nil)).IfNotExists().Exec(ctx) + _, err = db.NewCreateTable().Model((*Permission)(nil)).IfNotExists().Exec(ctx) + _, err = db.NewCreateTable().Model((*PermissionToRole)(nil)).IfNotExists().Exec(ctx) + _, err = db.NewCreateTable().Model((*UserToRole)(nil)).IfNotExists().Exec(ctx) if err != nil { return nil, err } diff --git a/backend/api/delete_event.go b/backend/api/events/delete.go similarity index 87% rename from backend/api/delete_event.go rename to backend/api/events/delete.go index 578229d..ca57870 100644 --- a/backend/api/delete_event.go +++ b/backend/api/events/delete.go @@ -1,4 +1,4 @@ -package api +package events import ( "context" @@ -8,7 +8,7 @@ import ( "fr.latosa-escrima/api/core" ) -func HandleDeleteEvent(w http.ResponseWriter, r *http.Request) { +func HandleDelete(w http.ResponseWriter, r *http.Request) { uuid := r.PathValue("event_uuid") var event core.Event res, err := core.DB.NewDelete(). diff --git a/backend/api/get_event.go b/backend/api/events/event.go similarity index 87% rename from backend/api/get_event.go rename to backend/api/events/event.go index 56bbc11..796a987 100644 --- a/backend/api/get_event.go +++ b/backend/api/events/event.go @@ -1,4 +1,4 @@ -package api +package events import ( "context" @@ -8,7 +8,7 @@ import ( core "fr.latosa-escrima/api/core" ) -func HandleGetEvent(w http.ResponseWriter, r *http.Request) { +func HandleEvent(w http.ResponseWriter, r *http.Request) { event_uuid := r.PathValue("event_uuid") var event core.Event _, err := core.DB.NewSelect().Model(&event).Where("uuid = ?", event_uuid).ScanAndCount(context.Background()) @@ -28,7 +28,7 @@ func HandleGetEvent(w http.ResponseWriter, r *http.Request) { return } -func HangleGetEvents(w http.ResponseWriter, r *http.Request) { +func HandleEvents(w http.ResponseWriter, r *http.Request) { var events []core.Event rowsCount, err := core.DB.NewSelect().Model(&events).ScanAndCount(context.Background()) if err != nil { @@ -38,7 +38,7 @@ func HangleGetEvents(w http.ResponseWriter, r *http.Request) { }.Respond(w, http.StatusInternalServerError) return } - + core.JSONSuccess{ Status: core.Success, Message: fmt.Sprintf("%d Event successfully sent", rowsCount), @@ -46,4 +46,3 @@ func HangleGetEvents(w http.ResponseWriter, r *http.Request) { }.Respond(w, http.StatusOK) return } - diff --git a/backend/api/new_event.go b/backend/api/events/new.go similarity index 89% rename from backend/api/new_event.go rename to backend/api/events/new.go index cade7c5..c253e11 100644 --- a/backend/api/new_event.go +++ b/backend/api/events/new.go @@ -1,4 +1,4 @@ -package api +package events import ( "context" @@ -8,7 +8,7 @@ import ( core "fr.latosa-escrima/api/core" ) -func HandleCreateEvent(w http.ResponseWriter, r *http.Request) { +func HandleNew(w http.ResponseWriter, r *http.Request) { var event core.Event err := json.NewDecoder(r.Body).Decode(&event) if err != nil { diff --git a/backend/api/update_event.go b/backend/api/events/update.go similarity index 94% rename from backend/api/update_event.go rename to backend/api/events/update.go index 753f40a..58dd47e 100644 --- a/backend/api/update_event.go +++ b/backend/api/events/update.go @@ -1,4 +1,4 @@ -package api +package events import ( "context" @@ -10,7 +10,7 @@ import ( "github.com/google/uuid" ) -func HandleUpdateEvent(w http.ResponseWriter, r *http.Request) { +func HandleUpdate(w http.ResponseWriter, r *http.Request) { var event core.Event err := json.NewDecoder(r.Body).Decode(&event) if err != nil { diff --git a/backend/api/get_blog.go b/backend/api/get_blog.go index 540ec74..65a8dfe 100644 --- a/backend/api/get_blog.go +++ b/backend/api/get_blog.go @@ -1,4 +1,4 @@ -package api +package blogs import ( "context" @@ -8,10 +8,12 @@ import ( core "fr.latosa-escrima/api/core" ) -func HandleGetBlog(w http.ResponseWriter, r *http.Request) { +func HandleBlog(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + blog_uuid := r.PathValue("uuid") - - var blog core.Blog + + var blog core.Blog _, err := core.DB.NewSelect(). Model(&blog). Where("blog_id = ?", blog_uuid). @@ -19,18 +21,18 @@ func HandleGetBlog(w http.ResponseWriter, r *http.Request) { ScanAndCount(context.Background()) if err != nil { core.JSONError{ - Status: core.Error, + Status: core.Error, Message: err.Error(), }.Respond(w, http.StatusNotAcceptable) - return + return } - + core.JSONSuccess{ Status: core.Success, Message: "Status OK", - Data: blog, + Data: blog, }.Respond(w, http.StatusOK) - return + return } func HandleGetBlogs(w http.ResponseWriter, r *http.Request) { diff --git a/backend/api/delete_media.go b/backend/api/media/delete.go similarity index 90% rename from backend/api/delete_media.go rename to backend/api/media/delete.go index cdf66da..fbb6903 100644 --- a/backend/api/delete_media.go +++ b/backend/api/media/delete.go @@ -1,4 +1,4 @@ -package api +package media import ( "context" @@ -9,7 +9,7 @@ import ( "fr.latosa-escrima/api/core" ) -func HandleDeleteMedia(w http.ResponseWriter, r *http.Request) { +func HandleDelete(w http.ResponseWriter, r *http.Request) { uuid := r.PathValue("media_uuid") var media core.Media res, err := core.DB.NewDelete(). diff --git a/backend/api/get_media.go b/backend/api/media/media.go similarity index 91% rename from backend/api/get_media.go rename to backend/api/media/media.go index 42f0012..ce36d05 100644 --- a/backend/api/get_media.go +++ b/backend/api/media/media.go @@ -1,4 +1,4 @@ -package api +package media import ( "context" @@ -11,7 +11,7 @@ import ( "fr.latosa-escrima/utils" ) -func HandleGetMedia(w http.ResponseWriter, r *http.Request) { +func HandleMedia(w http.ResponseWriter, r *http.Request) { queryParams := r.URL.Query() page, err := strconv.Atoi(queryParams.Get("page")) limit, err := strconv.Atoi(queryParams.Get("limit")) @@ -69,7 +69,7 @@ func HandleGetMedia(w http.ResponseWriter, r *http.Request) { }.Respond(w, http.StatusOK) } -func HandleGetMediaDetails(w http.ResponseWriter, r *http.Request) { +func HandleMediaDetails(w http.ResponseWriter, r *http.Request) { uuid := r.PathValue("media_uuid") var media core.Media err := core.DB.NewSelect(). @@ -96,7 +96,7 @@ func HandleGetMediaDetails(w http.ResponseWriter, r *http.Request) { }.Respond(w, http.StatusOK) } -func HandleGetMediaFile(w http.ResponseWriter, r *http.Request) { +func HandleMediaFile(w http.ResponseWriter, r *http.Request) { uuid := r.PathValue("media_uuid") var media core.Media err := core.DB.NewSelect(). diff --git a/backend/api/media/update.go b/backend/api/media/update.go new file mode 100644 index 0000000..83e39a0 --- /dev/null +++ b/backend/api/media/update.go @@ -0,0 +1,3 @@ +package media + +// TODO diff --git a/backend/api/upload_media.go b/backend/api/media/upload.go similarity index 97% rename from backend/api/upload_media.go rename to backend/api/media/upload.go index bbdfd45..91fc6db 100644 --- a/backend/api/upload_media.go +++ b/backend/api/media/upload.go @@ -1,4 +1,4 @@ -package api +package media import ( "context" @@ -14,7 +14,7 @@ import ( "github.com/google/uuid" ) -func HandleUploadMedia(w http.ResponseWriter, r *http.Request) { +func HandleUpload(w http.ResponseWriter, r *http.Request) { // Parse the multipart form err := r.ParseMultipartForm(10 << 20) // Limit file size to 10 MB if err != nil { diff --git a/backend/api/verify_media.go b/backend/api/media/verify.go similarity index 93% rename from backend/api/verify_media.go rename to backend/api/media/verify.go index 3f29876..d0c37b1 100644 --- a/backend/api/verify_media.go +++ b/backend/api/media/verify.go @@ -1,4 +1,4 @@ -package api +package media import ( "encoding/json" @@ -18,7 +18,7 @@ type FileArgs struct { SizeByte int64 `json:"size"` } -func HandleVerifyMedia(w http.ResponseWriter, r *http.Request) { +func HandleVerify(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { core.JSONError{ diff --git a/backend/api/auth.go b/backend/api/middlewares.go similarity index 52% rename from backend/api/auth.go rename to backend/api/middlewares.go index 87919ec..1296c47 100644 --- a/backend/api/auth.go +++ b/backend/api/middlewares.go @@ -2,99 +2,33 @@ package api import ( "context" - "encoding/json" "fmt" - "io" "net/http" + "os" "strings" - "time" core "fr.latosa-escrima/api/core" + "fr.latosa-escrima/api/users" "fr.latosa-escrima/utils" "github.com/golang-jwt/jwt/v5" ) -var MySigningKey = []byte("COUCOU") - -type LoginArgs struct { - Email string `json:"email"` - Password string `json:"password"` -} - -type Claims struct { - UserID string `json:"user_id"` - jwt.RegisteredClaims -} - -func HandleLogin(w http.ResponseWriter, r *http.Request) { - if r.Body == nil { - core.JSONError{ - Status: core.Error, - Message: "No body has been provided.", - }.Respond(w, http.StatusNoContent) - return - } - - body, err := io.ReadAll(r.Body) - fmt.Println(body) - if err != nil { - core.JSONError{ - Status: core.Error, - Message: err.Error(), - }.Respond(w, http.StatusNoContent) - return - } - var login LoginArgs - err = json.Unmarshal(body, &login) - if err != nil { - core.JSONError{ - Status: core.Error, - Message: err.Error(), - }.Respond(w, http.StatusNoContent) - return - } - - user, err := core.Verify(context.Background(), login.Email, login.Password) - if user == nil { - core.JSONError{ - Status: core.Error, - Message: "User not found.", - }.Respond(w, http.StatusNotFound) - return - } - if err != nil { - core.JSONError{ - Status: core.Error, - Message: err.Error(), - }.Respond(w, http.StatusNoContent) - return - } - - claims := Claims{ - UserID: user.UserID.String(), - RegisteredClaims: jwt.RegisteredClaims{ - Issuer: "latosa-escrima.fr", - Subject: "authentification", - ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24)), - IssuedAt: jwt.NewNumericDate(time.Now()), - }, - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - signed, err := token.SignedString(MySigningKey) - if err != nil { - core.JSONError{ - Status: core.Error, - Message: err.Error(), - }.Respond(w, http.StatusNoContent) - return - } - - core.JSONSuccess{ - Status: core.Success, - Message: "JWT Created", - Data: signed, - }.Respond(w, http.StatusCreated) +func CORS(next http.Handler) http.Handler { + CORS_AllowOrigin := os.Getenv("CORS_AllowOrigin") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Allow all origins (can restrict to specific origins) + w.Header().Set("Access-Control-Allow-Origin", CORS_AllowOrigin) + // Allow certain HTTP methods (you can customize these as needed) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH") + // Allow certain headers (you can add more as needed) + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-CSRF-Token") + w.Header().Set("Access-Control-Allow-Credentials", "true") + // Handle OPTIONS pre-flight request + if r.Method == http.MethodOptions { + return + } + next.ServeHTTP(w, r) + }) } func AuthJWT(next http.Handler) http.Handler { @@ -125,7 +59,7 @@ func AuthJWT(next http.Handler) http.Handler { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } - return MySigningKey, nil + return users.MySigningKey, nil }) if err != nil || !token.Valid { diff --git a/backend/api/permissions/delete.go b/backend/api/permissions/delete.go new file mode 100644 index 0000000..1b7b952 --- /dev/null +++ b/backend/api/permissions/delete.go @@ -0,0 +1 @@ +package permissions diff --git a/backend/api/permissions/new.go b/backend/api/permissions/new.go new file mode 100644 index 0000000..1b7b952 --- /dev/null +++ b/backend/api/permissions/new.go @@ -0,0 +1 @@ +package permissions diff --git a/backend/api/permissions/permission.go b/backend/api/permissions/permission.go new file mode 100644 index 0000000..1b7b952 --- /dev/null +++ b/backend/api/permissions/permission.go @@ -0,0 +1 @@ +package permissions diff --git a/backend/api/permissions/permissions.go b/backend/api/permissions/permissions.go new file mode 100644 index 0000000..1b7b952 --- /dev/null +++ b/backend/api/permissions/permissions.go @@ -0,0 +1 @@ +package permissions diff --git a/backend/api/permissions/update.go b/backend/api/permissions/update.go new file mode 100644 index 0000000..1b7b952 --- /dev/null +++ b/backend/api/permissions/update.go @@ -0,0 +1 @@ +package permissions diff --git a/backend/api/roles/add_permission.go b/backend/api/roles/add_permission.go new file mode 100644 index 0000000..3258a66 --- /dev/null +++ b/backend/api/roles/add_permission.go @@ -0,0 +1 @@ +package roles diff --git a/backend/api/roles/delete.go b/backend/api/roles/delete.go new file mode 100644 index 0000000..3258a66 --- /dev/null +++ b/backend/api/roles/delete.go @@ -0,0 +1 @@ +package roles diff --git a/backend/api/roles/new.go b/backend/api/roles/new.go new file mode 100644 index 0000000..3258a66 --- /dev/null +++ b/backend/api/roles/new.go @@ -0,0 +1 @@ +package roles diff --git a/backend/api/roles/permissions.go b/backend/api/roles/permissions.go new file mode 100644 index 0000000..3258a66 --- /dev/null +++ b/backend/api/roles/permissions.go @@ -0,0 +1 @@ +package roles diff --git a/backend/api/roles/remove_permission.go b/backend/api/roles/remove_permission.go new file mode 100644 index 0000000..3258a66 --- /dev/null +++ b/backend/api/roles/remove_permission.go @@ -0,0 +1 @@ +package roles diff --git a/backend/api/roles/role.go b/backend/api/roles/role.go new file mode 100644 index 0000000..3258a66 --- /dev/null +++ b/backend/api/roles/role.go @@ -0,0 +1 @@ +package roles diff --git a/backend/api/roles/roles.go b/backend/api/roles/roles.go new file mode 100644 index 0000000..3258a66 --- /dev/null +++ b/backend/api/roles/roles.go @@ -0,0 +1 @@ +package roles diff --git a/backend/api/roles/update.go b/backend/api/roles/update.go new file mode 100644 index 0000000..3258a66 --- /dev/null +++ b/backend/api/roles/update.go @@ -0,0 +1 @@ +package roles diff --git a/backend/api/delete_shortcode.go b/backend/api/shortcodes/delete.go similarity index 85% rename from backend/api/delete_shortcode.go rename to backend/api/shortcodes/delete.go index c6a8346..67ea9fb 100644 --- a/backend/api/delete_shortcode.go +++ b/backend/api/shortcodes/delete.go @@ -1,4 +1,4 @@ -package api +package shortcodes import ( "context" @@ -7,7 +7,7 @@ import ( core "fr.latosa-escrima/api/core" ) -func HandleDeleteShortcode(w http.ResponseWriter, r *http.Request) { +func HandleDelete(w http.ResponseWriter, r *http.Request) { code := r.PathValue("shortcode") _, err := core.DB.NewDelete(). Model((*core.Shortcode)(nil)). diff --git a/backend/api/new_shortcode.go b/backend/api/shortcodes/new.go similarity index 90% rename from backend/api/new_shortcode.go rename to backend/api/shortcodes/new.go index 471e8a6..2663bca 100644 --- a/backend/api/new_shortcode.go +++ b/backend/api/shortcodes/new.go @@ -1,4 +1,4 @@ -package api +package shortcodes import ( "context" @@ -8,7 +8,7 @@ import ( "fr.latosa-escrima/api/core" ) -func HandleCreateShortcode(w http.ResponseWriter, r *http.Request) { +func HandleNew(w http.ResponseWriter, r *http.Request) { var shortcode core.Shortcode err := json.NewDecoder(r.Body).Decode(&shortcode) if err != nil { diff --git a/backend/api/get_shortcode.go b/backend/api/shortcodes/shortcode.go similarity index 86% rename from backend/api/get_shortcode.go rename to backend/api/shortcodes/shortcode.go index 1fdcc72..83b2645 100644 --- a/backend/api/get_shortcode.go +++ b/backend/api/shortcodes/shortcode.go @@ -1,4 +1,4 @@ -package api +package shortcodes import ( "context" @@ -7,7 +7,7 @@ import ( "fr.latosa-escrima/api/core" ) -func HandleGetShortcode(w http.ResponseWriter, r *http.Request) { +func HandleShortcode(w http.ResponseWriter, r *http.Request) { code := r.PathValue("shortcode") var shortcode core.Shortcode err := core.DB.NewSelect(). diff --git a/backend/api/get_shortcodes.go b/backend/api/shortcodes/shortcodes.go similarity index 84% rename from backend/api/get_shortcodes.go rename to backend/api/shortcodes/shortcodes.go index 2c48c7f..e27d0c7 100644 --- a/backend/api/get_shortcodes.go +++ b/backend/api/shortcodes/shortcodes.go @@ -1,4 +1,4 @@ -package api +package shortcodes import ( "context" @@ -7,7 +7,7 @@ import ( "fr.latosa-escrima/api/core" ) -func HandleGetShortcodes(w http.ResponseWriter, r *http.Request) { +func HandleShortcodes(w http.ResponseWriter, r *http.Request) { var shortcodes []core.Shortcode err := core.DB.NewSelect().Model(&shortcodes).Scan(context.Background()) if err != nil { diff --git a/backend/api/update_shortcode.go b/backend/api/shortcodes/update.go similarity index 95% rename from backend/api/update_shortcode.go rename to backend/api/shortcodes/update.go index 17c98f9..08dc035 100644 --- a/backend/api/update_shortcode.go +++ b/backend/api/shortcodes/update.go @@ -1,4 +1,4 @@ -package api +package shortcodes import ( "context" @@ -20,7 +20,7 @@ type UpdateShortcodeArgs struct { MediaID *uuid.UUID `json:"media_id,omitempty"` // Nullable reference to another table's ID } -func HandleUpdateShortcode(w http.ResponseWriter, r *http.Request) { +func HandleUpdate(w http.ResponseWriter, r *http.Request) { var updateArgs UpdateShortcodeArgs err := json.NewDecoder(r.Body).Decode(&updateArgs) if err != nil { diff --git a/backend/api/users/auth.go b/backend/api/users/auth.go new file mode 100644 index 0000000..e6cfeb0 --- /dev/null +++ b/backend/api/users/auth.go @@ -0,0 +1,96 @@ +package users + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + core "fr.latosa-escrima/api/core" + "github.com/golang-jwt/jwt/v5" +) + +var MySigningKey = []byte("COUCOU") + +type LoginArgs struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type Claims struct { + UserID string `json:"user_id"` + jwt.RegisteredClaims +} + +func HandleLogin(w http.ResponseWriter, r *http.Request) { + if r.Body == nil { + core.JSONError{ + Status: core.Error, + Message: "No body has been provided.", + }.Respond(w, http.StatusNoContent) + return + } + + body, err := io.ReadAll(r.Body) + fmt.Println(body) + if err != nil { + core.JSONError{ + Status: core.Error, + Message: err.Error(), + }.Respond(w, http.StatusNoContent) + return + } + var login LoginArgs + err = json.Unmarshal(body, &login) + if err != nil { + core.JSONError{ + Status: core.Error, + Message: err.Error(), + }.Respond(w, http.StatusNoContent) + return + } + + user, err := core.Verify(context.Background(), login.Email, login.Password) + if user == nil { + core.JSONError{ + Status: core.Error, + Message: "User not found.", + }.Respond(w, http.StatusNotFound) + return + } + if err != nil { + core.JSONError{ + Status: core.Error, + Message: err.Error(), + }.Respond(w, http.StatusNoContent) + return + } + + claims := Claims{ + UserID: user.UserID.String(), + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "latosa-escrima.fr", + Subject: "authentification", + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString(MySigningKey) + if err != nil { + core.JSONError{ + Status: core.Error, + Message: err.Error(), + }.Respond(w, http.StatusNoContent) + return + } + + core.JSONSuccess{ + Status: core.Success, + Message: "JWT Created", + Data: signed, + }.Respond(w, http.StatusCreated) +} diff --git a/backend/api/delete_user.go b/backend/api/users/delete.go similarity index 87% rename from backend/api/delete_user.go rename to backend/api/users/delete.go index 23f1ffd..766a150 100644 --- a/backend/api/delete_user.go +++ b/backend/api/users/delete.go @@ -1,4 +1,4 @@ -package api +package users import ( "context" @@ -7,7 +7,7 @@ import ( "fr.latosa-escrima/api/core" ) -func HandleDeleteUser(w http.ResponseWriter, r *http.Request) { +func HandleDelete(w http.ResponseWriter, r *http.Request) { uuid := r.PathValue("user_uuid") _, err := core.DB.NewDelete(). Model((*core.User)(nil)). diff --git a/backend/api/get_me.go b/backend/api/users/me.go similarity index 85% rename from backend/api/get_me.go rename to backend/api/users/me.go index 6ad406e..dfc6c97 100644 --- a/backend/api/get_me.go +++ b/backend/api/users/me.go @@ -1,4 +1,4 @@ -package api +package users import ( "net/http" @@ -7,7 +7,7 @@ import ( "github.com/golang-jwt/jwt/v5" ) -func HandleGetMe(w http.ResponseWriter, r *http.Request) { +func HandleMe(w http.ResponseWriter, r *http.Request) { token, ok := r.Context().Value("token").(*jwt.Token) if !ok { core.JSONError{ @@ -29,5 +29,5 @@ func HandleGetMe(w http.ResponseWriter, r *http.Request) { uuid := claims["user_id"].(string) r.SetPathValue("user_uuid", uuid) - HandleGetUser(w, r) + HandleUser(w, r) } diff --git a/backend/api/new_user.go b/backend/api/users/new.go similarity index 91% rename from backend/api/new_user.go rename to backend/api/users/new.go index 7c35ad0..ed31f09 100644 --- a/backend/api/new_user.go +++ b/backend/api/users/new.go @@ -1,4 +1,4 @@ -package api +package users import ( "context" @@ -9,7 +9,7 @@ import ( core "fr.latosa-escrima/api/core" ) -func HandleCreateUser(w http.ResponseWriter, r *http.Request) { +func HandleNew(w http.ResponseWriter, r *http.Request) { var user core.User err := json.NewDecoder(r.Body).Decode(&user) if err != nil { diff --git a/backend/api/update_user.go b/backend/api/users/update.go similarity index 78% rename from backend/api/update_user.go rename to backend/api/users/update.go index 9d2397e..78ed85f 100644 --- a/backend/api/update_user.go +++ b/backend/api/users/update.go @@ -1,4 +1,4 @@ -package api +package users import ( "context" @@ -13,15 +13,15 @@ import ( ) type UpdateUserArgs struct { - FirstName *string `json:"firstname,omitempty"` - LastName *string `json:"lastname,omitempty"` - Email *string `json:"email,omitempty"` - Password *string `json:"password,omitempty"` - Phone *string `json:"phone,omitempty"` - Role *core.Role `json:"role,omitempty"` + FirstName *string `json:"firstname,omitempty"` + LastName *string `json:"lastname,omitempty"` + Email *string `json:"email,omitempty"` + Password *string `json:"password,omitempty"` + Phone *string `json:"phone,omitempty"` + Attributes *core.UserAttributes `json:"attributes"` } -func HandleUpdateUser(w http.ResponseWriter, r *http.Request) { +func HandleUpdate(w http.ResponseWriter, r *http.Request) { var updateArgs UpdateUserArgs err := json.NewDecoder(r.Body).Decode(&updateArgs) if err != nil { diff --git a/backend/api/get_user.go b/backend/api/users/user.go similarity index 90% rename from backend/api/get_user.go rename to backend/api/users/user.go index 71b8eb9..4ae00cc 100644 --- a/backend/api/get_user.go +++ b/backend/api/users/user.go @@ -1,4 +1,4 @@ -package api +package users import ( "context" @@ -7,7 +7,7 @@ import ( "fr.latosa-escrima/api/core" ) -func HandleGetUser(w http.ResponseWriter, r *http.Request) { +func HandleUser(w http.ResponseWriter, r *http.Request) { uuid := r.PathValue("user_uuid") var user core.User count, err := core.DB.NewSelect(). diff --git a/backend/api/get_users.go b/backend/api/users/users.go similarity index 79% rename from backend/api/get_users.go rename to backend/api/users/users.go index 707807e..e368f7c 100644 --- a/backend/api/get_users.go +++ b/backend/api/users/users.go @@ -1,4 +1,4 @@ -package api +package users import ( "context" @@ -8,7 +8,7 @@ import ( "fr.latosa-escrima/utils" ) -func HandleGetUsers(w http.ResponseWriter, r *http.Request) { +func HandleUsers(w http.ResponseWriter, r *http.Request) { var users []core.User count, err := core.DB.NewSelect(). Model(&users). @@ -20,9 +20,9 @@ func HandleGetUsers(w http.ResponseWriter, r *http.Request) { }) if count == 0 { - core.JSONError{ - Status: core.Error, - Message: "Not users.", + core.JSONSuccess{ + Status: core.Success, + Message: "No users.", }.Respond(w, http.StatusNotFound) return } @@ -35,7 +35,6 @@ func HandleGetUsers(w http.ResponseWriter, r *http.Request) { return } - // TODO : Remove password core.JSONSuccess{ Status: core.Success, Message: "Users found.", diff --git a/backend/cmd/migrate/main.go b/backend/cmd/migrate/main.go new file mode 100644 index 0000000..a3ee4e6 --- /dev/null +++ b/backend/cmd/migrate/main.go @@ -0,0 +1,219 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + "os" + "strings" + + "fr.latosa-escrima/api/core" + "fr.latosa-escrima/cmd/migrate/migrations" + "github.com/joho/godotenv" + "github.com/uptrace/bun/dialect/pgdialect" + "github.com/uptrace/bun/driver/pgdriver" + "github.com/uptrace/bun/extra/bundebug" + "github.com/uptrace/bun/migrate" + + "github.com/urfave/cli/v2" + + "github.com/uptrace/bun" +) + +func main() { + err := godotenv.Load() + if err != nil { + log.Fatalf("Error loading .env file: %v", err) + } + environ := os.Getenv("ENVIRONMENT") + + hostname := os.Getenv("DATABASE_HOSTNAME") + postgres_port := os.Getenv("POSTGRES_DOCKER_PORT") + if environ == "DEV" { + hostname = "localhost" + postgres_port = os.Getenv("POSTGRES_PORT") + } + + dsn := core.DSN{ + Hostname: hostname, + Port: postgres_port, + DBName: os.Getenv("POSTGRES_DB"), + User: os.Getenv("POSTGRES_USER"), + Password: os.Getenv("POSTGRES_PASSWORD"), + } + fmt.Println(dsn.ToString()) + sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(dsn.ToString()))) + db := bun.NewDB(sqldb, pgdialect.New()) + + db.AddQueryHook(bundebug.NewQueryHook(bundebug.WithVerbose(true))) + + defer db.Close() + if err != nil { + panic(err) + } + + db.AddQueryHook(bundebug.NewQueryHook( + bundebug.WithEnabled(false), + bundebug.FromEnv(), + )) + + app := &cli.App{ + Name: "bun", + + Commands: []*cli.Command{ + newDBCommand(migrate.NewMigrator(db, migrations.Migrations)), + }, + } + if err := app.Run(os.Args); err != nil { + log.Fatal(err) + } +} + +func newDBCommand(migrator *migrate.Migrator) *cli.Command { + return &cli.Command{ + Name: "db", + Usage: "database migrations", + Subcommands: []*cli.Command{ + { + Name: "init", + Usage: "create migration tables", + Action: func(c *cli.Context) error { + return migrator.Init(c.Context) + }, + }, + { + Name: "migrate", + Usage: "migrate database", + Action: func(c *cli.Context) error { + if err := migrator.Lock(c.Context); err != nil { + return err + } + defer migrator.Unlock(c.Context) //nolint:errcheck + + group, err := migrator.Migrate(c.Context) + if err != nil { + return err + } + if group.IsZero() { + fmt.Printf("there are no new migrations to run (database is up to date)\n") + return nil + } + fmt.Printf("migrated to %s\n", group) + return nil + }, + }, + { + Name: "rollback", + Usage: "rollback the last migration group", + Action: func(c *cli.Context) error { + if err := migrator.Lock(c.Context); err != nil { + return err + } + defer migrator.Unlock(c.Context) //nolint:errcheck + + group, err := migrator.Rollback(c.Context) + if err != nil { + return err + } + if group.IsZero() { + fmt.Printf("there are no groups to roll back\n") + return nil + } + fmt.Printf("rolled back %s\n", group) + return nil + }, + }, + { + Name: "lock", + Usage: "lock migrations", + Action: func(c *cli.Context) error { + return migrator.Lock(c.Context) + }, + }, + { + Name: "unlock", + Usage: "unlock migrations", + Action: func(c *cli.Context) error { + return migrator.Unlock(c.Context) + }, + }, + { + Name: "create_go", + Usage: "create Go migration", + Action: func(c *cli.Context) error { + name := strings.Join(c.Args().Slice(), "_") + mf, err := migrator.CreateGoMigration(c.Context, name) + if err != nil { + return err + } + fmt.Printf("created migration %s (%s)\n", mf.Name, mf.Path) + return nil + }, + }, + { + Name: "create_sql", + Usage: "create up and down SQL migrations", + Action: func(c *cli.Context) error { + name := strings.Join(c.Args().Slice(), "_") + files, err := migrator.CreateSQLMigrations(c.Context, name) + if err != nil { + return err + } + + for _, mf := range files { + fmt.Printf("created migration %s (%s)\n", mf.Name, mf.Path) + } + + return nil + }, + }, + { + Name: "create_tx_sql", + Usage: "create up and down transactional SQL migrations", + Action: func(c *cli.Context) error { + name := strings.Join(c.Args().Slice(), "_") + files, err := migrator.CreateTxSQLMigrations(c.Context, name) + if err != nil { + return err + } + + for _, mf := range files { + fmt.Printf("created transaction migration %s (%s)\n", mf.Name, mf.Path) + } + + return nil + }, + }, + { + Name: "status", + Usage: "print migrations status", + Action: func(c *cli.Context) error { + ms, err := migrator.MigrationsWithStatus(c.Context) + if err != nil { + return err + } + fmt.Printf("migrations: %s\n", ms) + fmt.Printf("unapplied migrations: %s\n", ms.Unapplied()) + fmt.Printf("last migration group: %s\n", ms.LastGroup()) + return nil + }, + }, + { + Name: "mark_applied", + Usage: "mark migrations as applied without actually running them", + Action: func(c *cli.Context) error { + group, err := migrator.Migrate(c.Context, migrate.WithNopMigration()) + if err != nil { + return err + } + if group.IsZero() { + fmt.Printf("there are no new migrations to mark as applied\n") + return nil + } + fmt.Printf("marked as applied %s\n", group) + return nil + }, + }, + }, + } +} diff --git a/backend/cmd/migrate/migrations/20250128162856_add_users_attributes.down.sql b/backend/cmd/migrate/migrations/20250128162856_add_users_attributes.down.sql new file mode 100644 index 0000000..029e2fc --- /dev/null +++ b/backend/cmd/migrate/migrations/20250128162856_add_users_attributes.down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN attributes; diff --git a/backend/cmd/migrate/migrations/20250128162856_add_users_attributes.up.sql b/backend/cmd/migrate/migrations/20250128162856_add_users_attributes.up.sql new file mode 100644 index 0000000..cbab347 --- /dev/null +++ b/backend/cmd/migrate/migrations/20250128162856_add_users_attributes.up.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN attributes jsonb; diff --git a/backend/cmd/migrate/migrations/main.go b/backend/cmd/migrate/migrations/main.go new file mode 100644 index 0000000..781c88d --- /dev/null +++ b/backend/cmd/migrate/migrations/main.go @@ -0,0 +1,11 @@ +package migrations + +import "github.com/uptrace/bun/migrate" + +var Migrations = migrate.NewMigrations() + +func init() { + if err := Migrations.DiscoverCaller(); err != nil { + panic(err) + } +} diff --git a/backend/go.mod b/backend/go.mod index 51ec100..48f4945 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -12,6 +12,12 @@ require ( github.com/uptrace/bun/driver/pgdriver v1.2.8 ) +require ( + github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect +) + require ( github.com/gorilla/csrf v1.7.2 // direct github.com/gorilla/securecookie v1.1.2 // indirect @@ -26,6 +32,7 @@ require ( github.com/puzpuzpuz/xsync/v3 v3.4.0 // indirect github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect github.com/uptrace/bun/extra/bundebug v1.2.8 // direct + github.com/urfave/cli/v2 v2.27.5 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect golang.org/x/crypto v0.31.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index dcaf24f..6bc358e 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,5 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= +github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= @@ -27,6 +29,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4= github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= @@ -39,10 +43,14 @@ github.com/uptrace/bun/driver/pgdriver v1.2.8 h1:5XrNn/9enSrWhhrUpz+6PY9S1vcg/jh github.com/uptrace/bun/driver/pgdriver v1.2.8/go.mod h1:cwRRwqabgePwYBiLlXtbeNmPD7LGJnqP21J2ZKP4ah8= github.com/uptrace/bun/extra/bundebug v1.2.8 h1:Epv0ycLOnoKWPky+rufP2F/PrcSlKkd4tmVIFOdq90A= github.com/uptrace/bun/extra/bundebug v1.2.8/go.mod h1:ucnmuPw/5ePbNFj2SPmV0lQh3ZvL+3HCrpvRxIYZyWQ= +github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= +github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/backend/main.go b/backend/main.go index 0613c85..37962d9 100644 --- a/backend/main.go +++ b/backend/main.go @@ -12,7 +12,12 @@ import ( _ "github.com/lib/pq" "fr.latosa-escrima/api" + "fr.latosa-escrima/api/blogs" "fr.latosa-escrima/api/core" + "fr.latosa-escrima/api/events" + "fr.latosa-escrima/api/media" + "fr.latosa-escrima/api/shortcodes" + "fr.latosa-escrima/api/users" "github.com/gorilla/csrf" ) @@ -21,22 +26,6 @@ var CORS_AllowOrigin string func handler(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "

Hello, World!

") } -func Cors(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Allow all origins (can restrict to specific origins) - w.Header().Set("Access-Control-Allow-Origin", CORS_AllowOrigin) - // Allow certain HTTP methods (you can customize these as needed) - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH") - // Allow certain headers (you can add more as needed) - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-CSRF-Token") - w.Header().Set("Access-Control-Allow-Credentials", "true") - // Handle OPTIONS pre-flight request - if r.Method == http.MethodOptions { - return - } - next.ServeHTTP(w, r) - }) -} func main() { err := godotenv.Load() @@ -48,7 +37,6 @@ func main() { port := os.Getenv("BACKEND_DOCKER_PORT") hostname := os.Getenv("DATABASE_HOSTNAME") postgres_port := os.Getenv("POSTGRES_DOCKER_PORT") - CORS_AllowOrigin = os.Getenv("CORS_AllowOrigin") if environ == "DEV" { port = os.Getenv("BACKEND_PORT") hostname = "localhost" @@ -85,67 +73,67 @@ func main() { Handler: handler, Middlewares: []core.Middleware{api.Methods("get")}}, "/users/login": { - Handler: api.HandleLogin, + Handler: users.HandleLogin, Middlewares: []core.Middleware{api.Methods("POST")}}, "/users/me": { - Handler: api.HandleGetMe, + Handler: users.HandleMe, Middlewares: []core.Middleware{api.Methods("GET"), api.AuthJWT}}, "/users": { - Handler: api.HandleGetUsers, + Handler: users.HandleUsers, Middlewares: []core.Middleware{api.Methods("GET"), api.AuthJWT}}, "/users/new": { - Handler: api.HandleCreateUser, + Handler: users.HandleNew, Middlewares: []core.Middleware{api.Methods("POST"), api.AuthJWT}}, "/users/{user_uuid}": { - Handler: api.HandleGetUser, + Handler: users.HandleUser, Middlewares: []core.Middleware{api.Methods("GET"), api.AuthJWT}}, "/users/{user_uuid}/delete": { - Handler: api.HandleDeleteUser, + Handler: users.HandleDelete, Middlewares: []core.Middleware{api.Methods("DELETE"), api.AuthJWT}}, "/users/{user_uuid}/update": { - Handler: api.HandleUpdateUser, + Handler: users.HandleUpdate, Middlewares: []core.Middleware{api.Methods("PATCH"), api.AuthJWT}}, - "/events": { - Handler: api.HangleGetEvents, + "/events": { + Handler: events.HandleEvents, Middlewares: []core.Middleware{api.Methods("GET")}}, "/events/new": { - Handler: api.HandleCreateEvent, + Handler: events.HandleNew, Middlewares: []core.Middleware{api.Methods("POST")}}, "/events/{event_uuid}": { - Handler: api.HandleGetEvent, + Handler: events.HandleEvent, Middlewares: []core.Middleware{api.Methods("GET")}}, "/events/{event_uuid}/delete": { - Handler: api.HandleDeleteEvent, + Handler: events.HandleDelete, Middlewares: []core.Middleware{api.Methods("DELETE")}}, "/events/{event_uuid}/update": { - Handler: api.HandleUpdateEvent, + Handler: events.HandleUpdate, Middlewares: []core.Middleware{api.Methods("PATCH")}}, "/blogs/new": { - Handler: api.HandleCreateBlog, + Handler: blogs.HandleNew, Middlewares: []core.Middleware{api.Methods(("POST"))}}, "/blogs/{uuid}": { - Handler: api.HandleGetBlog, + Handler: blogs.HandleBlog, Middlewares: []core.Middleware{api.Methods("GET")}}, "/media/upload": { - Handler: api.HandleUploadMedia, + Handler: media.HandleUpload, Middlewares: []core.Middleware{api.Methods("POST"), api.AuthJWT}}, "/media/verify": { - Handler: api.HandleVerifyMedia, + Handler: media.HandleVerify, Middlewares: []core.Middleware{api.Methods("POST"), api.AuthJWT}, }, // Paginated media response "/media/": { - Handler: api.HandleGetMedia, + Handler: media.HandleMedia, Middlewares: []core.Middleware{api.Methods("GET")}, }, // Unique element "/media/{media_uuid}": { - Handler: api.HandleGetMediaDetails, + Handler: media.HandleMediaDetails, Middlewares: []core.Middleware{api.Methods("GET")}, }, // Get the image, video, GIF etc. "/media/{media_uuid}/file": { - Handler: api.HandleGetMediaFile, + Handler: media.HandleMediaFile, Middlewares: []core.Middleware{api.Methods("GET")}, }, // "/media/{media_uuid}/update": { @@ -153,29 +141,81 @@ func main() { // Middlewares: []core.Middleware{api.Methods("PATCH"), api.AuthJWT}, // }, "/media/{media_uuid}/delete": { - Handler: api.HandleDeleteMedia, + Handler: media.HandleDelete, Middlewares: []core.Middleware{api.Methods("DELETE"), api.AuthJWT}, }, "/shortcodes/new": { - Handler: api.HandleCreateShortcode, + Handler: shortcodes.HandleNew, Middlewares: []core.Middleware{api.Methods("POST"), api.AuthJWT}, }, "/shortcodes/": { - Handler: api.HandleGetShortcodes, + Handler: shortcodes.HandleShortcodes, Middlewares: []core.Middleware{api.Methods("GET"), api.AuthJWT}, }, "/shortcodes/{shortcode}": { - Handler: api.HandleGetShortcode, + Handler: shortcodes.HandleShortcode, Middlewares: []core.Middleware{api.Methods("GET")}, }, "/shortcodes/{shortcode}/delete": { - Handler: api.HandleDeleteShortcode, + Handler: shortcodes.HandleDelete, Middlewares: []core.Middleware{api.Methods("DELETE"), api.AuthJWT}, }, "/shortcodes/{shortcode}/update": { - Handler: api.HandleUpdateShortcode, + Handler: shortcodes.HandleUpdate, Middlewares: []core.Middleware{api.Methods("PATCH"), api.AuthJWT}, }, + // "/roles": { + // Handler: nil, + // Middlewares: []core.Middleware{api.Methods("GET"), api.AuthJWT}, + // }, + // "/roles/new": { + // Handler: nil, + // Middlewares: []core.Middleware{api.Methods("POST"), api.AuthJWT}, + // }, + // "/roles/{role_uuid}": { + // Handler: nil, + // Middlewares: []core.Middleware{api.Methods("GET"), api.AuthJWT}, + // }, + // "/roles/{role_uuid}/update": { + // Handler: nil, + // Middlewares: []core.Middleware{api.Methods("PATCH"), api.AuthJWT}, + // }, + // "/roles/{role_uuid}/delete": { + // Handler: nil, + // Middlewares: []core.Middleware{api.Methods("DELETE"), api.AuthJWT}, + // }, + // "/permissions": { + // Handler: nil, + // Middlewares: []core.Middleware{api.Methods("GET"), api.AuthJWT}, + // }, + // "/permissions/new": { + // Handler: nil, + // Middlewares: []core.Middleware{api.Methods("POST"), api.AuthJWT}, + // }, + // "/permissions/{permission_id}": { + // Handler: nil, + // Middlewares: []core.Middleware{api.Methods("PATCH"), api.AuthJWT}, + // }, + // "/permissions/{permission_id}/update": { + // Handler: nil, + // Middlewares: []core.Middleware{api.Methods("PATCH"), api.AuthJWT}, + // }, + // "/permissions/{permission_id}/delete": { + // Handler: nil, + // Middlewares: []core.Middleware{api.Methods("DELETE"), api.AuthJWT}, + // }, + // "/roles/{role_uuid}/permissions/": { + // Handler: nil, + // Middlewares: []core.Middleware{api.Methods("GET"), api.AuthJWT}, + // }, + // "/roles/{role_uuid}/permissions/{permission_id}/add": { + // Handler: nil, + // Middlewares: []core.Middleware{api.Methods("POST"), api.AuthJWT}, + // }, + // "/roles/{role_uuid}/permissions/{permission_id}/remove": { + // Handler: nil, + // Middlewares: []core.Middleware{api.Methods("POST"), api.AuthJWT}, + // }, "/contact": { Handler: api.HandleContact, Middlewares: []core.Middleware{api.Methods("POST"), CSRFMiddleware}, @@ -187,7 +227,7 @@ func main() { }) fmt.Printf("Serving on port %s\n", port) - err = http.ListenAndServe(fmt.Sprintf(":%s", port), Cors(mux)) + err = http.ListenAndServe(fmt.Sprintf(":%s", port), api.CORS(mux)) if err != nil { fmt.Printf("Error starting server: %s\n", err) } diff --git a/frontend/app/(auth)/dashboard/members/new/page.tsx b/frontend/app/(auth)/dashboard/members/new/page.tsx deleted file mode 100644 index 33cc8b8..0000000 --- a/frontend/app/(auth)/dashboard/members/new/page.tsx +++ /dev/null @@ -1,233 +0,0 @@ -"use client"; - -import { z } from "zod"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; - -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import useApiMutation from "@/hooks/use-api"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" - -const formSchema = z.object({ - firstname: z.string().min(2, { message: "Prénom trop court." }), - lastname: z.string().min(2, { message: "Nom trop court." }), - role: z.enum(["admin", "user"], { message: "Rôle invalide." }), - phone: z - .string() - .max(10, { message: "Un numéro de téléphone à 10 chiffres." }) - .optional(), - email: z.string().email({ message: "Email invalide." }) -}); - -export default function CreateMemberForm() { - const { - trigger - } = useApiMutation( - "/users/new", - { onSuccess: () => console.log("Member created") }, - "POST", - true, - false - ) - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - firstname: "", - lastname: "", - role: "user", - phone: "", - email: "", - }, - }); - - async function onSubmit(values: z.infer) { - try { - const res = await trigger(values) - if (!res) throw new Error("The server hasn't responded."); - if (res.status === "Error") throw new Error(res.message); - } catch (error) { - console.error("Form submission error", error); - } - console.log("submited"); - } - - return ( -
- - - Création de membre - - Remplissez les différents champs pour créer un membre. - Un code sera envoyé par mail à l'utilisateur. - Il pourra ensuite modifier le mot de passe de son compte avec ce code. - - - -
- -
- {/* Firstname Field */} - ( - - - Prénom - - - - - - - )} - /> -
- {/* Firstname Field */} - ( - - - Nom - - - - - - - )} - /> - - {/* Email Field */} - ( - - - Email - - - - - - - )} - /> - - ( - - - Role - - - - - - - )} - /> - - {/* Phone Field */} - ( - - - Phone number - - - - - - - )} - /> - - -
-
-
- -
-
-
- ); -} diff --git a/frontend/app/(auth)/dashboard/members/page.tsx b/frontend/app/(auth)/dashboard/members/page.tsx new file mode 100644 index 0000000..771ed9d --- /dev/null +++ b/frontend/app/(auth)/dashboard/members/page.tsx @@ -0,0 +1,10 @@ +"use server"; +import MembersTable from "@/components/members-table"; + +export default async function Page({}) { + return ( +
+ +
+ ); +} diff --git a/frontend/components/app-sidebar.tsx b/frontend/components/app-sidebar.tsx index dd6266a..06db639 100644 --- a/frontend/components/app-sidebar.tsx +++ b/frontend/components/app-sidebar.tsx @@ -55,8 +55,8 @@ const data = { isActive: true, items: [ { - title: "Création d'un membre", - url: "/dashboard/members/new", + title: "Liste des membres", + url: "/dashboard/members", }, ], }, diff --git a/frontend/components/member-dialog.tsx b/frontend/components/member-dialog.tsx new file mode 100644 index 0000000..730fb4c --- /dev/null +++ b/frontend/components/member-dialog.tsx @@ -0,0 +1,280 @@ +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "./ui/select"; + +const memberSchema = z.object({ + userId: z.string().optional(), + firstname: z.string().min(1, "Prénom est requis."), + lastname: z.string().min(1, "Nom de famille est requis."), + email: z.string().email("Adresse email invalide."), + password: z + .string() + .min(6, "Le mot de passe doit avoir au moins 6 caractères.") + .optional(), + phone: z.string().regex(/^\d{10}$/, "Le numéro doit avoir 10 chiffres."), + role: z.string().min(1, "Le rôle est requis."), +}); + +const updateMemberSchema = memberSchema.partial(); + +export type Member = z.infer; + +interface MemberDialogProps { + isOpen: boolean; + onClose: () => void; + member: Member | null; + onSave: (member: Member) => void; +} + +export default function MemberDialog({ + isOpen, + onClose, + member, + onSave, +}: MemberDialogProps) { + const schema = member?.userId ? updateMemberSchema : memberSchema; + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: member?.userId + ? member + : { + userId: "", + firstname: "", + lastname: "", + email: "", + password: "", + phone: "", + role: "", + }, + }); + + useEffect(() => { + if (member) { + form.reset(member); + } else { + form.reset({ + userId: "", + firstname: "", + lastname: "", + email: "", + password: "", + phone: "", + role: "", + }); + } + }, [member, form]); + + const onSubmit = (data: Member) => { + onSave(data); + onClose(); + }; + + return ( + + + + + {member + ? "Mise à jour du membre" + : "Créer un nouveau membre"} + + +
+ +
+ {/* Firstname Field */} + ( + + + Prénom + + + + + + + )} + /> +
+ {/* Firstname Field */} + ( + + + Nom + + + + + + + )} + /> + + {/* Email Field */} + ( + + + Email + + + + + + + )} + /> + {/* Password Field */} + {!member?.userId && ( + ( + + + Mot de passe + + + + + + + )} + /> + )} + + ( + + + Role + + + + + + + )} + /> + + {/* Phone Field */} + ( + + + Phone number + + + + + + + )} + /> +
+
+ + + +
+ +
+
+ ); +} diff --git a/frontend/components/members-table.tsx b/frontend/components/members-table.tsx index 399ec59..dacb454 100644 --- a/frontend/components/members-table.tsx +++ b/frontend/components/members-table.tsx @@ -12,93 +12,28 @@ import { import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { UpdateMemberDialog } from "./UpdateMemberDialog"; -import { AddMemberDialog } from "./AddMemberDialog"; +import MemberDialog, { Member } from "./member-dialog"; +import * as z from "zod"; +import { request, useApi } from "@/hooks/use-api"; +import { + CircleX, + Loader2, + Trash2, + UserRoundPen, + UserRoundPlus, +} from "lucide-react"; -interface Member { - user_id: string; - firstname: string; - lastname: string; - email: string; - password: string; - phone: string; - role: string; -} - -const initialMembers: Member[] = [ - // Add some sample data here - { - user_id: "1", - firstname: "John", - lastname: "Doe", - email: "john@example.com", - password: "********", - phone: "1234567890", - role: "User", - }, - { - user_id: "1", - firstname: "John", - lastname: "Doe", - email: "john@example.com", - password: "********", - phone: "1234567890", - role: "User", - }, - { - user_id: "1", - firstname: "John", - lastname: "Doe", - email: "john@example.com", - password: "********", - phone: "1234567890", - role: "User", - }, - { - user_id: "1", - firstname: "John", - lastname: "Doe", - email: "john@example.com", - password: "********", - phone: "1234567890", - role: "User", - }, - { - user_id: "1", - firstname: "John", - lastname: "Doe", - email: "john@example.com", - password: "********", - phone: "1234567890", - role: "User", - }, - { - user_id: "1", - firstname: "John", - lastname: "Doe", - email: "john@example.com", - password: "********", - phone: "1234567890", - role: "User", - }, - { - user_id: "1", - firstname: "John", - lastname: "Doe", - email: "john@example.com", - password: "********", - phone: "1234567890", - role: "User", - }, - // Add more sample members... -]; - -export function MembersTable() { - const [members, setMembers] = useState(initialMembers); +export default function MembersTable() { + const { + data: members, + error, + mutate, + success, + isLoading, + } = useApi("/users", undefined, true, false); const [selectMode, setSelectMode] = useState(false); const [selectedMembers, setSelectedMembers] = useState([]); - const [updateDialogOpen, setUpdateDialogOpen] = useState(false); - const [addDialogOpen, setAddDialogOpen] = useState(false); + const [dialogOpen, setDialogOpen] = useState(false); const [currentMember, setCurrentMember] = useState(null); const toggleSelectMode = () => { @@ -114,124 +49,135 @@ export function MembersTable() { ); }; - const handleUpdate = (member: Member) => { + const handleOpenDialog = (member: Member | null) => { setCurrentMember(member); - setUpdateDialogOpen(true); + setDialogOpen(true); }; - const handleDelete = (userId: string) => { - setMembers((prev) => - prev.filter((member) => member.user_id !== userId), - ); + const handleSaveMember = async (savedMember: Member) => { + if (savedMember.userId) { + const res = await request( + `/users/${savedMember.userId}/update`, + { + body: savedMember, + requiresAuth: true, + method: "PATCH", + }, + ); + if (res.status === "Success") mutate(); + // Update existing member + // setMembers((prev) => + // prev.map((m) => + // m.user_id === savedMember.user_id ? savedMember : m, + // ), + // ); + } else { + delete savedMember.userId; + const res = await request("/users/new", { + body: savedMember, + method: "POST", + requiresAuth: true, + }); + if (res.status === "Success") mutate(); + } }; - const handleAdd = (newMember: Member) => { - setMembers((prev) => [ - ...prev, - { ...newMember, user_id: String(prev.length + 1) }, - ]); + const handleDelete = async (userId: string) => { + const res = await request(`/users/${userId}/delete`, { + method: "DELETE", + requiresAuth: true, + }); + if (res.status === "Success") mutate(); }; return (
-
- + {selectMode && ( - Select + Selectionner )} - User ID - First Name - Last Name + Prénom + Nom Email - Password - Phone - Role + Téléphone + Rôle Actions - {members.map((member) => ( - - {selectMode && ( + {isLoading && } + {members && + members.map((member) => ( + + {selectMode && ( + + + toggleMemberSelection( + member.userId!, + ) + } + /> + + )} - - toggleMemberSelection( - member.user_id, - ) - } - /> + {member.firstname} - )} - {member.user_id} - {member.firstname} - {member.lastname} - {member.email} - {member.password} - {member.phone} - {member.role} - - - - - - ))} + {member.lastname} + {member.email} + {member.phone} + {member.role} + + + + + + ))}
- setUpdateDialogOpen(false)} + setDialogOpen(false)} member={currentMember} - onUpdate={(updatedMember) => { - setMembers((prev) => - prev.map((m) => - m.user_id === updatedMember.user_id - ? updatedMember - : m, - ), - ); - setUpdateDialogOpen(false); - }} - /> - setAddDialogOpen(false)} - onAdd={handleAdd} + onSave={handleSaveMember} />
);