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:
SashegDev
2026-06-06 22:39:14 +00:00
commit 096c4d0a2d
40 changed files with 5054 additions and 0 deletions
+454
View File
@@ -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
}