096c4d0a2d
Серверная часть (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 — спецификация протокола
455 lines
12 KiB
Go
455 lines
12 KiB
Go
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
|
|
}
|