Initial commit: JustAMessenger v0.1.0
Серверная часть (Go): - WebSocket сервер с бинарным протоколом - XChaCha20-Poly1305 шифрование - zstd сжатие с дедупликацией (64KB чанки) - SQLite хранилище (WAL режим) - Управление гильдиями, каналами, ролями - Федерация между серверами (ed25519) - REST API + WebSocket endpoints Клиентская часть (Flutter): - Material Design 3 тёмная тема (Discord-like) - WebSocket соединение с сервером - Экраны: сплэш, логин, домашний, гильдии, чат - Модели: пользователи, гильдии, каналы, сообщения, роли - Сервисы: соединение, API, криптография, тема - Виджеты: иконки гильдий, сообщения, ввод чата - Web сборка (PWA) Документация: - AGENTS.md — контекст для ИИ ассистентов - docs/protocol.md — спецификация протокола
This commit is contained in:
@@ -0,0 +1,454 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user