From 4e76ee468b9a4d2b6ccdaf49dde2996abefc73d8 Mon Sep 17 00:00:00 2001 From: Nell Date: Mon, 17 Nov 2025 01:06:03 +0100 Subject: [PATCH] init --- app/app.go | 21 +- database/database.go | 7 + models/attachment.go | 8 + models/category.go | 10 +- models/channel.go | 8 + models/channel_user.go | 5 +- models/message.go | 8 + models/server.go | 8 + network/http/web/api/category.go | 11 +- network/http/web/api/channel.go | 11 +- network/http/web/api/message.go | 11 +- network/http/web/api/server.go | 40 ++- network/http/web/api/server_dto.go | 4 + network/http/web/auth.go | 2 +- network/http/web/{main.go => init.go} | 13 +- openapi.yaml | 492 ++++++++++++++++++++++++++ 16 files changed, 617 insertions(+), 42 deletions(-) rename network/http/web/{main.go => init.go} (70%) create mode 100644 openapi.yaml diff --git a/app/app.go b/app/app.go index 5bd8564..53e0209 100644 --- a/app/app.go +++ b/app/app.go @@ -42,6 +42,17 @@ func NewApp(cfg *config.Config) *App { // Channel pour les erreurs errChan := make(chan error, 1) + // database init + dbConfig := database.DBConfig{ + Driver: cfg.Database.Type, + DSN: cfg.GetDSN(), + } + + // 1) Initialiser la DB + if err := database.Initialize(dbConfig); err != nil { + //return fmt.Errorf("failed to initialize database: %w", err) + } + // Servers udpServer := udp.NewServer(cfg.Server.BindAddr) httpServer := http.NewServer(cfg.Server.BindAddr) @@ -61,16 +72,6 @@ func (app *App) Run() error { // Context pour gérer l'arrêt gracieux defer app.cancel() - dbConfig := database.DBConfig{ - Driver: app.cfg.Database.Type, - DSN: app.cfg.GetDSN(), - } - - // 1) Initialiser la DB - if err := database.Initialize(dbConfig); err != nil { - return fmt.Errorf("failed to initialize database: %w", err) - } - // (optionnel) garder une référence locale si tu veux utiliser app.db ailleurs app.db = database.DB diff --git a/database/database.go b/database/database.go index bb3aed0..c7c23a3 100644 --- a/database/database.go +++ b/database/database.go @@ -46,6 +46,13 @@ func Initialize(config DBConfig) error { return fmt.Errorf("erreur lors de la connexion à la base de données: %w", err) } + // Activation du mode foreign keys pour SQLite + if config.Driver == "sqlite" { + if err := DB.Exec("PRAGMA foreign_keys = ON;").Error; err != nil { + return fmt.Errorf("impossible d'activer les foreign keys SQLite: %w", err) + } + } + log.Printf("Connexion à la base de données (%s) établie avec succès", config.Driver) return nil } diff --git a/models/attachment.go b/models/attachment.go index 125df5a..827a4e3 100644 --- a/models/attachment.go +++ b/models/attachment.go @@ -4,6 +4,7 @@ import ( "time" "github.com/google/uuid" + "gorm.io/gorm" ) type Attachment struct { @@ -17,3 +18,10 @@ type Attachment struct { // Relation optionnelle vers le message Message *Message `gorm:"foreignKey:MessageID" json:"message,omitempty"` } + +func (s *Attachment) BeforeCreate(tx *gorm.DB) (err error) { + if s.ID == uuid.Nil { + s.ID = uuid.New() + } + return nil +} diff --git a/models/category.go b/models/category.go index 5904b13..484953b 100644 --- a/models/category.go +++ b/models/category.go @@ -4,6 +4,7 @@ import ( "time" "github.com/google/uuid" + "gorm.io/gorm" ) type Category struct { @@ -14,5 +15,12 @@ type Category struct { UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` // Relation optionnelle vers le serveur - Server *Server `gorm:"foreignKey:ServerID" json:"server,omitempty"` + Server *Server `gorm:"foreignKey:ServerID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"server,omitempty"` +} + +func (s *Category) BeforeCreate(tx *gorm.DB) (err error) { + if s.ID == uuid.Nil { + s.ID = uuid.New() + } + return nil } diff --git a/models/channel.go b/models/channel.go index 1a5a226..2df5738 100644 --- a/models/channel.go +++ b/models/channel.go @@ -4,6 +4,7 @@ import ( "time" "github.com/google/uuid" + "gorm.io/gorm" ) // #[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize)] @@ -48,3 +49,10 @@ type Channel struct { Server *Server `gorm:"foreignKey:ServerID" json:"server,omitempty"` Category *Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"` } + +func (s *Channel) BeforeCreate(tx *gorm.DB) (err error) { + if s.ID == uuid.Nil { + s.ID = uuid.New() + } + return nil +} diff --git a/models/channel_user.go b/models/channel_user.go index acea53c..0d0351f 100644 --- a/models/channel_user.go +++ b/models/channel_user.go @@ -7,8 +7,9 @@ import ( ) type ChannelUser struct { - ChannelID uuid.UUID `gorm:"primaryKey" json:"channel_id"` - UserID uuid.UUID `gorm:"primaryKey" json:"user_id"` + ID uuid.UUID `gorm:"primaryKey" json:"id"` + ChannelID uuid.UUID `gorm:"index;not null" json:"channel_id"` + UserID uuid.UUID `gorm:"index;not null" json:"user_id"` Role string `gorm:"default:'member'" json:"role"` JoinedAt time.Time `gorm:"autoCreateTime" json:"joined_at"` diff --git a/models/message.go b/models/message.go index 2af4e42..77e30f1 100644 --- a/models/message.go +++ b/models/message.go @@ -4,6 +4,7 @@ import ( "time" "github.com/google/uuid" + "gorm.io/gorm" ) // #[derive(Debug, Clone, FromRow, Serialize, Deserialize)] @@ -26,3 +27,10 @@ type Message struct { EditedAt *time.Time `gorm:"default:null" json:"edited_at,omitempty"` ReplyToID *uuid.UUID `gorm:"index" json:"reply_to_id,omitempty"` } + +func (s *Message) BeforeCreate(tx *gorm.DB) (err error) { + if s.ID == uuid.Nil { + s.ID = uuid.New() + } + return nil +} diff --git a/models/server.go b/models/server.go index 0409db5..17185f4 100644 --- a/models/server.go +++ b/models/server.go @@ -4,6 +4,7 @@ import ( "time" "github.com/google/uuid" + "gorm.io/gorm" ) type Server struct { @@ -13,3 +14,10 @@ type Server struct { CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` } + +func (s *Server) BeforeCreate(tx *gorm.DB) (err error) { + if s.ID == uuid.Nil { + s.ID = uuid.New() + } + return nil +} diff --git a/network/http/web/api/category.go b/network/http/web/api/category.go index 8584557..4c2f507 100644 --- a/network/http/web/api/category.go +++ b/network/http/web/api/category.go @@ -19,12 +19,11 @@ func NewCategoryHandler(h *handler.Handler) *CategoryHandler { } func (h *CategoryHandler) RegisterRoutes(rg *gin.RouterGroup) { - category := rg.Group("/category") - category.GET("/", h.getCategories) - category.GET("/:id/", h.getCategory) - category.POST("/", h.addCategory) - category.PUT("/:id/", h.updateCategory) - category.DELETE("/:id/", h.deleteCategory) + rg.GET("/", h.getCategories) + rg.GET("/:id/", h.getCategory) + rg.POST("/", h.addCategory) + rg.PUT("/:id/", h.updateCategory) + rg.DELETE("/:id/", h.deleteCategory) } func (h *CategoryHandler) getCategories(c *gin.Context) { diff --git a/network/http/web/api/channel.go b/network/http/web/api/channel.go index 59a35fe..ce6c296 100644 --- a/network/http/web/api/channel.go +++ b/network/http/web/api/channel.go @@ -19,12 +19,11 @@ func NewChannelHandler(h *handler.Handler) *ChannelHandler { } func (h *ChannelHandler) RegisterRoutes(rg *gin.RouterGroup) { - channel := rg.Group("/channel") - channel.GET("/", h.getChannels) - channel.GET("/:id/", h.getChannel) - channel.POST("/", h.addChannel) - channel.PUT("/:id/", h.updateChannel) - channel.DELETE("/:id/", h.deleteChannel) + rg.GET("/", h.getChannels) + rg.GET("/:id/", h.getChannel) + rg.POST("/", h.addChannel) + rg.PUT("/:id/", h.updateChannel) + rg.DELETE("/:id/", h.deleteChannel) } diff --git a/network/http/web/api/message.go b/network/http/web/api/message.go index d7b93fc..615bf89 100644 --- a/network/http/web/api/message.go +++ b/network/http/web/api/message.go @@ -19,12 +19,11 @@ func NewMessageHandler(h *handler.Handler) *MessageHandler { } func (h *MessageHandler) RegisterRoutes(rg *gin.RouterGroup) { - message := rg.Group("/message") - message.GET("/", h.getMessages) - message.GET("/:id/", h.getMessage) - message.POST("/", h.addMessage) - message.PUT("/:id/", h.updateMessage) - message.DELETE("/:id/", h.deleteMessage) + rg.GET("/", h.getMessages) + rg.GET("/:id/", h.getMessage) + rg.POST("/", h.addMessage) + rg.PUT("/:id/", h.updateMessage) + rg.DELETE("/:id/", h.deleteMessage) } func (h *MessageHandler) getMessages(c *gin.Context) { diff --git a/network/http/web/api/server.go b/network/http/web/api/server.go index 5237256..d3ce75e 100644 --- a/network/http/web/api/server.go +++ b/network/http/web/api/server.go @@ -19,12 +19,12 @@ func NewServerHandler(h *handler.Handler) *ServerHandler { } func (h *ServerHandler) RegisterRoutes(rg *gin.RouterGroup) { - server := rg.Group("/server") - server.GET("/", h.serverList) - server.GET("/:id/", h.serverDetail) - server.POST("/", h.serverAdd) - server.PUT("/:id/", h.serverUpdate) - server.DELETE("/:id/", h.serverDelete) + rg.GET("/", h.serverList) + rg.GET("/:id/", h.serverDetail) + rg.POST("/", h.serverAdd) + rg.PUT("/:id/", h.serverUpdate) + rg.DELETE("/:id/", h.serverDelete) + rg.POST("/:id/join/", h.serverJoin) } func (h *ServerHandler) serverList(c *gin.Context) { @@ -126,3 +126,31 @@ func (h *ServerHandler) serverDelete(c *gin.Context) { c.Status(http.StatusNoContent) } + +func (h *ServerHandler) serverJoin(c *gin.Context) { + id := c.Param("id") + + var server models.Server + result := h.DB.First(&server, "id = ?", id) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "Server not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()}) + return + } + + var req JoinServerRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if server.Password != req.Password { + c.JSON(http.StatusForbidden, gin.H{"error": "Wrong password"}) + return + } + + c.JSON(http.StatusOK, server) +} diff --git a/network/http/web/api/server_dto.go b/network/http/web/api/server_dto.go index 6912668..4dd9a12 100644 --- a/network/http/web/api/server_dto.go +++ b/network/http/web/api/server_dto.go @@ -9,3 +9,7 @@ type UpdateServerRequest struct { Name string `json:"name" binding:"required"` Password *string `json:"password,omitempty"` } + +type JoinServerRequest struct { + Password *string `json:"password,omitempty"` +} diff --git a/network/http/web/auth.go b/network/http/web/auth.go index 3d152ca..d812e89 100644 --- a/network/http/web/auth.go +++ b/network/http/web/auth.go @@ -54,7 +54,7 @@ func (h *AuthHandler) authenticate(c *gin.Context) { // Generate token claims := jwt.MapClaims{ "user_id": user.ID, - "expiration_date": time.Now().Add(time.Hour * 72).Unix(), + "expiration_date": time.Now().Add(time.Minute * 15).Unix(), "creation_date": time.Now().Unix(), } diff --git a/network/http/web/main.go b/network/http/web/init.go similarity index 70% rename from network/http/web/main.go rename to network/http/web/init.go index 90d6969..a194b36 100644 --- a/network/http/web/main.go +++ b/network/http/web/init.go @@ -32,14 +32,19 @@ func CreateRouter() *gin.Engine { apiGroup := router.Group("/api") { - serverHandler.RegisterRoutes(apiGroup) - channelHandler.RegisterRoutes(apiGroup) - categoryHandler.RegisterRoutes(apiGroup) - messageHandler.RegisterRoutes(apiGroup) + serverHandler.RegisterRoutes(apiGroup.Group("/server")) + channelHandler.RegisterRoutes(apiGroup.Group("/channel")) + categoryHandler.RegisterRoutes(apiGroup.Group("/category")) + messageHandler.RegisterRoutes(apiGroup.Group("/message")) } router.GET("/health", healthcheck) + // Expose OpenAPI specification (static file placed at repository root) + router.GET("/openapi.yaml", func(c *gin.Context) { + c.File("openapi.yaml") + }) + return router } diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..8af24a1 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,492 @@ +openapi: 3.1.0 +info: + title: OXSpeak Server API + version: 1.0.0 + description: API HTTP exposée par le serveur OXSpeak. +servers: + - url: http://localhost:7000 +paths: + /health: + get: + summary: Vérifie l'état du service + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: ok + + /auth/channel/login/: + get: + summary: Authentification par clé publique (renvoie un JWT) + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AuthRequest' + responses: + '200': + description: Jeton JWT généré + content: + application/json: + schema: + $ref: '#/components/schemas/AuthResponse' + '400': + description: Requête invalide + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Erreur interne + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /api/server/: + get: + summary: Liste des serveurs + responses: + '200': + description: Liste de serveurs + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Server' + '500': + description: Erreur serveur + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + summary: Crée un serveur + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateServerRequest' + responses: + '201': + description: Serveur créé + content: + application/json: + schema: + $ref: '#/components/schemas/Server' + '400': { description: Requête invalide } + '500': { description: Erreur interne } + + /api/server/{id}/: + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + get: + summary: Détail d'un serveur + responses: + '200': + description: Serveur + content: + application/json: + schema: + $ref: '#/components/schemas/Server' + '404': { description: Introuvable } + '500': { description: Erreur interne } + put: + summary: Met à jour un serveur + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateServerRequest' + responses: + '200': + description: Serveur mis à jour + content: + application/json: + schema: + $ref: '#/components/schemas/Server' + '400': { description: Requête invalide } + '404': { description: Introuvable } + '500': { description: Erreur interne } + delete: + summary: Supprime un serveur + responses: + '204': { description: Supprimé } + '404': { description: Introuvable } + '500': { description: Erreur interne } + + /api/category/: + get: + summary: Liste des catégories + responses: + '200': + description: Liste des catégories + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Category' + '500': { description: Erreur interne } + post: + summary: Crée une catégorie + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateCategoryRequest' + responses: + '201': + description: Catégorie créée + content: + application/json: + schema: + $ref: '#/components/schemas/Category' + '400': { description: Requête invalide } + '500': { description: Erreur interne } + + /api/category/{id}/: + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + get: + summary: Détail d'une catégorie + responses: + '200': + description: Catégorie + content: + application/json: + schema: + $ref: '#/components/schemas/Category' + '404': { description: Introuvable } + '500': { description: Erreur interne } + put: + summary: Met à jour une catégorie + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateCategoryRequest' + responses: + '200': + description: Catégorie mise à jour + content: + application/json: + schema: + $ref: '#/components/schemas/Category' + '400': { description: Requête invalide } + '404': { description: Introuvable } + '500': { description: Erreur interne } + delete: + summary: Supprime une catégorie + responses: + '204': { description: Supprimé } + '404': { description: Introuvable } + '500': { description: Erreur interne } + + /api/channel/: + get: + summary: Liste des canaux + responses: + '200': + description: Liste des canaux + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Channel' + '500': { description: Erreur interne } + post: + summary: Crée un canal + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateChannelRequest' + responses: + '201': + description: Canal créé + content: + application/json: + schema: + $ref: '#/components/schemas/Channel' + '400': { description: Requête invalide } + '500': { description: Erreur interne } + + /api/channel/{id}/: + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + get: + summary: Détail d'un canal + responses: + '200': + description: Canal + content: + application/json: + schema: + $ref: '#/components/schemas/Channel' + '404': { description: Introuvable } + '500': { description: Erreur interne } + put: + summary: Met à jour un canal + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateChannelRequest' + responses: + '200': + description: Canal mis à jour + content: + application/json: + schema: + $ref: '#/components/schemas/Channel' + '400': { description: Requête invalide } + '404': { description: Introuvable } + '500': { description: Erreur interne } + delete: + summary: Supprime un canal + responses: + '204': { description: Supprimé } + '404': { description: Introuvable } + '500': { description: Erreur interne } + + /api/message/: + get: + summary: Liste des messages + responses: + '200': + description: Liste des messages + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Message' + '500': { description: Erreur interne } + post: + summary: Crée un message + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateMessageRequest' + responses: + '201': + description: Message créé + content: + application/json: + schema: + $ref: '#/components/schemas/Message' + '400': { description: Requête invalide } + '500': { description: Erreur interne } + + /api/message/{id}/: + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + get: + summary: Détail d'un message + responses: + '200': + description: Message + content: + application/json: + schema: + $ref: '#/components/schemas/Message' + '404': { description: Introuvable } + '500': { description: Erreur interne } + put: + summary: Met à jour un message + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateMessageRequest' + responses: + '200': + description: Message mis à jour + content: + application/json: + schema: + $ref: '#/components/schemas/Message' + '400': { description: Requête invalide } + '404': { description: Introuvable } + '500': { description: Erreur interne } + delete: + summary: Supprime un message + responses: + '204': { description: Supprimé } + '404': { description: Introuvable } + '500': { description: Erreur interne } + +components: + schemas: + UUID: + type: string + format: uuid + + Error: + type: object + properties: + error: + type: string + + AuthRequest: + type: object + required: [pub_key] + properties: + pub_key: + type: string + description: Clé publique du client + + AuthResponse: + type: object + properties: + JWT: + type: string + + Server: + type: object + properties: + id: { $ref: '#/components/schemas/UUID' } + name: { type: string } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + required: [id, name, created_at, updated_at] + + CreateServerRequest: + type: object + required: [name] + properties: + name: { type: string } + password: { type: string, nullable: true } + + UpdateServerRequest: + type: object + required: [name] + properties: + name: { type: string } + password: { type: string, nullable: true } + + Category: + type: object + properties: + id: { $ref: '#/components/schemas/UUID' } + server_id: { $ref: '#/components/schemas/UUID' } + name: { type: string } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + required: [id, server_id, name, created_at, updated_at] + + CreateCategoryRequest: + type: object + required: [server_id, name] + properties: + server_id: { $ref: '#/components/schemas/UUID' } + name: { type: string } + + UpdateCategoryRequest: + type: object + required: [name] + properties: + name: { type: string } + + Channel: + type: object + properties: + id: { $ref: '#/components/schemas/UUID' } + server_id: { $ref: '#/components/schemas/UUID' } + category_id: { $ref: '#/components/schemas/UUID' } + position: { type: integer, format: int32 } + type: { $ref: '#/components/schemas/ChannelType' } + name: { type: string, nullable: true } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + required: [id, position, type, created_at, updated_at] + + ChannelType: + type: string + enum: [text, voice, dm] + + CreateChannelRequest: + type: object + required: [type] + properties: + server_id: { $ref: '#/components/schemas/UUID' } + category_id: { $ref: '#/components/schemas/UUID' } + position: { type: integer, format: int32 } + type: { $ref: '#/components/schemas/ChannelType' } + name: { type: string, nullable: true } + + UpdateChannelRequest: + type: object + properties: + server_id: { $ref: '#/components/schemas/UUID' } + category_id: { $ref: '#/components/schemas/UUID' } + position: { type: integer, format: int32 } + type: { $ref: '#/components/schemas/ChannelType' } + name: { type: string, nullable: true } + + Message: + type: object + properties: + id: { $ref: '#/components/schemas/UUID' } + channel_id: { $ref: '#/components/schemas/UUID' } + user_id: { $ref: '#/components/schemas/UUID' } + content: { type: string } + created_at: { type: string, format: date-time } + edited_at: { type: string, format: date-time, nullable: true } + reply_to_id: { $ref: '#/components/schemas/UUID' } + required: [id, channel_id, user_id, content, created_at] + + CreateMessageRequest: + type: object + required: [channel_id, user_id, content] + properties: + channel_id: { $ref: '#/components/schemas/UUID' } + user_id: { $ref: '#/components/schemas/UUID' } + content: { type: string } + reply_to_id: { $ref: '#/components/schemas/UUID' } + + UpdateMessageRequest: + type: object + required: [content] + properties: + content: { type: string } + reply_to_id: { $ref: '#/components/schemas/UUID' }