package api import ( "encoding/json" "io" "log" "net/http" "os" "path/filepath" "strconv" "time" "github.com/google/uuid" "github.com/gorilla/websocket" "github.com/justamessenger/server/internal/channel" "github.com/justamessenger/server/internal/config" "github.com/justamessenger/server/internal/database" "github.com/justamessenger/server/internal/federation" "github.com/justamessenger/server/internal/models" "github.com/justamessenger/server/internal/role" "github.com/justamessenger/server/internal/server" ) var upgrader = websocket.Upgrader{ ReadBufferSize: 4096, WriteBufferSize: 4096, CheckOrigin: func(r *http.Request) bool { return true }, } type HTTPServer struct { srv *server.Server cfg *config.Config db *database.DB chanM *channel.Manager roleM *role.Manager fedM *federation.Manager mux *http.ServeMux httpS *http.Server } func New(srv *server.Server, cfg *config.Config) *HTTPServer { h := &HTTPServer{ srv: srv, cfg: cfg, db: srv.GetDB(), chanM: srv.GetChannelManager(), roleM: srv.GetRoleManager(), fedM: srv.GetFederationManager(), mux: http.NewServeMux(), } h.registerRoutes() return h } func (h *HTTPServer) registerRoutes() { h.mux.HandleFunc("/ws", h.handleWebSocket) h.mux.HandleFunc("/api/health", h.handleHealth) h.mux.HandleFunc("/api/guilds", h.handleGuilds) h.mux.HandleFunc("/api/guilds/", h.handleGuild) h.mux.HandleFunc("/api/channels", h.handleChannels) h.mux.HandleFunc("/api/channels/", h.handleChannel) h.mux.HandleFunc("/api/roles/", h.handleRoles) h.mux.HandleFunc("/api/messages/", h.handleMessages) h.mux.HandleFunc("/api/users/", h.handleUsers) h.mux.HandleFunc("/api/upload", h.handleUpload) h.mux.HandleFunc("/api/files/", h.handleFile) h.mux.HandleFunc("/federation/receive", h.handleFederationReceive) fs := http.FileServer(http.Dir(filepath.Join(h.cfg.DataDir, "files"))) h.mux.Handle("/files/", http.StripPrefix("/files/", fs)) } func (h *HTTPServer) Start(addr string) error { h.httpS = &http.Server{ Addr: addr, Handler: h.mux, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, } log.Printf("API server listening on %s", addr) return h.httpS.ListenAndServe() } func (h *HTTPServer) handleWebSocket(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Printf("WebSocket upgrade failed: %v", err) return } h.srv.HandleConnection(conn) } func (h *HTTPServer) handleHealth(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]interface{}{ "status": "ok", "name": h.cfg.ServerName, "version": "0.1.0", "uptime": time.Now().Unix(), }) } func (h *HTTPServer) handleGuilds(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: userID := r.URL.Query().Get("user_id") if userID == "" { http.Error(w, "user_id required", http.StatusBadRequest) return } rows, err := h.db.Query( `SELECT g.id, g.name, g.owner_id, g.icon, g.description, g.created_at FROM guilds g INNER JOIN guild_members gm ON g.id = gm.guild_id WHERE gm.user_id = ?`, userID, ) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() var guilds []*models.Guild for rows.Next() { g := &models.Guild{} rows.Scan(&g.ID, &g.Name, &g.OwnerID, &g.Icon, &g.Description, &g.CreatedAt) guilds = append(guilds, g) } json.NewEncoder(w).Encode(guilds) case http.MethodPost: var input struct { Name string `json:"name"` OwnerID string `json:"owner_id"` Description string `json:"description"` } if err := json.NewDecoder(r.Body).Decode(&input); err != nil { http.Error(w, "invalid body", http.StatusBadRequest) return } id := uuid.New().String() _, err := h.db.Exec( `INSERT INTO guilds (id, name, owner_id, description) VALUES (?, ?, ?, ?)`, id, input.Name, input.OwnerID, input.Description, ) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } h.db.Exec( `INSERT INTO guild_members (guild_id, user_id) VALUES (?, ?)`, id, input.OwnerID, ) h.roleM.CreateDefaultRoles(id) w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]string{"id": id}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (h *HTTPServer) handleGuild(w http.ResponseWriter, r *http.Request) { id := extractID(r.URL.Path, "/api/guilds/") if id == "" { http.Error(w, "guild id required", http.StatusBadRequest) return } switch r.Method { case http.MethodGet: var g models.Guild err := h.db.QueryRow( `SELECT id, name, owner_id, icon, description, created_at FROM guilds WHERE id = ?`, id, ).Scan(&g.ID, &g.Name, &g.OwnerID, &g.Icon, &g.Description, &g.CreatedAt) if err != nil { http.Error(w, "not found", http.StatusNotFound) return } channels, _ := h.chanM.ListByGuild(id) roles, _ := h.roleM.ListByGuild(id) categories, _ := h.chanM.ListCategories(id) json.NewEncoder(w).Encode(map[string]interface{}{ "guild": g, "channels": channels, "roles": roles, "categories": categories, }) case http.MethodDelete: h.db.Exec(`DELETE FROM guilds WHERE id = ?`, id) w.WriteHeader(http.StatusNoContent) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (h *HTTPServer) handleChannels(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var input struct { GuildID string `json:"guild_id"` CategoryID string `json:"category_id"` Name string `json:"name"` Type string `json:"type"` Topic string `json:"topic"` Position int `json:"position"` } if err := json.NewDecoder(r.Body).Decode(&input); err != nil { http.Error(w, "invalid body", http.StatusBadRequest) return } ch, err := h.chanM.Create(input.GuildID, input.CategoryID, input.Name, input.Type, input.Topic, input.Position) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(ch) } func (h *HTTPServer) handleChannel(w http.ResponseWriter, r *http.Request) { id := extractID(r.URL.Path, "/api/channels/") if id == "" { http.Error(w, "channel id required", http.StatusBadRequest) return } switch r.Method { case http.MethodGet: ch, err := h.chanM.Get(id) if err != nil { http.Error(w, "not found", http.StatusNotFound) return } json.NewEncoder(w).Encode(ch) case http.MethodPut: var input struct { Name string `json:"name"` Topic string `json:"topic"` Position int `json:"position"` } json.NewDecoder(r.Body).Decode(&input) if err := h.chanM.Update(id, input.Name, input.Topic, input.Position); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) case http.MethodDelete: if err := h.chanM.Delete(id); err != nil { http.Error(w, err.Error(), http.StatusNotFound) return } w.WriteHeader(http.StatusNoContent) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (h *HTTPServer) handleRoles(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: var input struct { GuildID string `json:"guild_id"` Name string `json:"name"` Color int `json:"color"` Position int `json:"position"` Permissions []string `json:"permissions"` } if err := json.NewDecoder(r.Body).Decode(&input); err != nil { http.Error(w, "invalid", http.StatusBadRequest) return } rl, err := h.roleM.Create(input.GuildID, input.Name, input.Color, input.Position, input.Permissions) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(rl) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (h *HTTPServer) handleMessages(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } channelID := r.URL.Query().Get("channel_id") if channelID == "" { http.Error(w, "channel_id required", http.StatusBadRequest) return } limitStr := r.URL.Query().Get("limit") limit := 50 if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 200 { limit = l } before := r.URL.Query().Get("before") var query string var args []interface{} if before != "" { query = `SELECT id, channel_id, author_id, content, encrypted, nonce, message_type, reply_to, pinned, edited_at, created_at FROM messages WHERE channel_id = ? AND id < ? ORDER BY created_at DESC LIMIT ?` args = []interface{}{channelID, before, limit} } else { query = `SELECT id, channel_id, author_id, content, encrypted, nonce, message_type, reply_to, pinned, edited_at, created_at FROM messages WHERE channel_id = ? ORDER BY created_at DESC LIMIT ?` args = []interface{}{channelID, limit} } rows, err := h.db.Query(query, args...) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() var messages []*models.Message for rows.Next() { m := &models.Message{} rows.Scan(&m.ID, &m.ChannelID, &m.AuthorID, &m.Content, &m.Encrypted, &m.Nonce, &m.MessageType, &m.ReplyTo, &m.Pinned, &m.EditedAt, &m.CreatedAt) messages = append(messages, m) } json.NewEncoder(w).Encode(messages) } func (h *HTTPServer) handleUsers(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } id := extractID(r.URL.Path, "/api/users/") if id == "" { http.Error(w, "user id required", http.StatusBadRequest) return } var u models.User err := h.db.QueryRow( `SELECT id, username, avatar, bio, public_key, created_at FROM users WHERE id = ?`, id, ).Scan(&u.ID, &u.Username, &u.Avatar, &u.Bio, &u.PublicKey, &u.CreatedAt) if err != nil { http.Error(w, "not found", http.StatusNotFound) return } json.NewEncoder(w).Encode(u) } func (h *HTTPServer) handleUpload(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } r.ParseMultipartForm(2 << 30) file, header, err := r.FormFile("file") if err != nil { http.Error(w, "no file", http.StatusBadRequest) return } defer file.Close() uploadDir := filepath.Join(h.cfg.DataDir, "files") os.MkdirAll(uploadDir, 0755) id := uuid.New().String() ext := filepath.Ext(header.Filename) filename := id + ext dst, err := os.Create(filepath.Join(uploadDir, filename)) if err != nil { http.Error(w, "failed to save", http.StatusInternalServerError) return } defer dst.Close() size, _ := io.Copy(dst, file) json.NewEncoder(w).Encode(map[string]interface{}{ "id": id, "filename": header.Filename, "size": size, "url": "/files/" + filename, }) } func (h *HTTPServer) handleFile(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } id := extractID(r.URL.Path, "/api/files/") if id == "" { http.Error(w, "file id required", http.StatusBadRequest) return } http.ServeFile(w, r, filepath.Join(h.cfg.DataDir, "files", id)) } func (h *HTTPServer) handleFederationReceive(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } h.fedM.HandleReceive(w, r) } func extractID(path, prefix string) string { if len(path) <= len(prefix) { return "" } id := path[len(prefix):] if idx := stringsIndex(id, "/"); idx >= 0 { id = id[:idx] } return id } func stringsIndex(s, substr string) int { for i := 0; i <= len(s)-len(substr); i++ { if s[i:i+len(substr)] == substr { return i } } return -1 }