backend barebone done, big chunk of work done
CI / Go (build + vet + test) (push) Canceled after 0s
CI / Web (build + lint) (push) Canceled after 0s

This commit is contained in:
vantablackTea
2026-09-27 19:42:49 +03:00
parent 1a669c948a
commit c79a3cfe68
36 changed files with 2607 additions and 17 deletions
+67
View File
@@ -0,0 +1,67 @@
package api
import (
"log/slog"
"net/http"
"messenger/internal/auth"
"messenger/internal/platform/config"
"messenger/internal/realtime"
"messenger/internal/store"
)
type apiHandler struct {
store *store.Store
tokens *auth.TokenManager
hub *realtime.Hub
log *slog.Logger
secure bool
}
// NewHandler builds the /api/v1 route tree with all middleware applied.
func NewHandler(st *store.Store, tm *auth.TokenManager, hub *realtime.Hub, log *slog.Logger, cfg *config.Config) http.Handler {
h := &apiHandler{
store: st,
tokens: tm,
hub: hub,
log: log,
secure: cfg.Environment == "production",
}
mux := http.NewServeMux()
authHandler := requireAuth(tm, http.HandlerFunc(h.websocket))
mux.HandleFunc("GET /api/v1/ping", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"message": "pong"})
})
mux.HandleFunc("POST /api/v1/auth/register", h.register)
mux.HandleFunc("POST /api/v1/auth/login", h.login)
mux.HandleFunc("POST /api/v1/auth/refresh", h.refresh)
mux.HandleFunc("POST /api/v1/auth/logout", h.logout)
mux.Handle("GET /api/v1/users/me", requireAuth(tm, h.me))
mux.Handle("GET /api/v1/users", requireAuth(tm, h.searchUsers))
mux.Handle("GET /api/v1/contacts", requireAuth(tm, h.listContacts))
mux.Handle("POST /api/v1/contacts", requireAuth(tm, h.addContact))
mux.Handle("DELETE /api/v1/contacts/{user_id}", requireAuth(tm, h.removeContact))
mux.Handle("GET /api/v1/conversations", requireAuth(tm, h.listConversations))
mux.Handle("POST /api/v1/conversations", requireAuth(tm, h.createDM))
mux.Handle("GET /api/v1/conversations/{id}/messages", requireAuth(tm, h.listMessages))
mux.Handle("POST /api/v1/conversations/{id}/messages", requireAuth(tm, h.sendMessage))
mux.Handle("GET /ws", authHandler)
return mux
}
func (h *apiHandler) websocket(w http.ResponseWriter, r *http.Request) {
userID, ok := userIDFrom(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthenticated")
return
}
h.hub.HandleWS(w, r, userID)
}
+172
View File
@@ -0,0 +1,172 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"regexp"
"time"
"messenger/internal/auth"
"messenger/internal/store"
)
var usernameRe = regexp.MustCompile(`^[a-z0-9_]{3,32}$`)
type userJSON struct {
ID int64 `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Avatar string `json:"avatar"`
CreatedAt time.Time `json:"created_at"`
}
func toUserJSON(u *store.User) userJSON {
return userJSON{
ID: u.ID,
Username: u.Username,
DisplayName: u.DisplayName,
Avatar: u.Avatar,
CreatedAt: u.CreatedAt,
}
}
type credentialsRequest struct {
Username string `json:"username"`
DisplayName string `json:"display_name"`
Password string `json:"password"`
}
func (h *apiHandler) register(w http.ResponseWriter, r *http.Request) {
var req credentialsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if !usernameRe.MatchString(req.Username) {
writeError(w, http.StatusBadRequest, "username must be 3-32 chars of [a-z0-9_]")
return
}
if req.DisplayName == "" || len(req.DisplayName) > 64 {
writeError(w, http.StatusBadRequest, "display_name must be 1-64 chars")
return
}
if len(req.Password) < 8 || len(req.Password) > 128 {
writeError(w, http.StatusBadRequest, "password must be 8-128 chars")
return
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
user, err := h.store.CreateUser(r.Context(), req.Username, req.DisplayName, hash)
if err != nil {
if errors.Is(err, store.ErrDuplicate) {
writeError(w, http.StatusConflict, "username already taken")
return
}
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
h.issueSession(w, r, user.ID)
writeJSON(w, http.StatusCreated, toUserJSON(user))
}
func (h *apiHandler) login(w http.ResponseWriter, r *http.Request) {
var req credentialsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
user, err := h.store.GetUserByUsername(r.Context(), req.Username)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
ok, err := auth.VerifyPassword(user.PasswordHash, req.Password)
if err != nil || !ok {
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
h.issueSession(w, r, user.ID)
writeJSON(w, http.StatusOK, toUserJSON(user))
}
func (h *apiHandler) refresh(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(refreshCookieName)
if err != nil {
writeError(w, http.StatusUnauthorized, "missing refresh token")
return
}
session, err := h.store.GetSessionByRefreshHash(r.Context(), auth.HashRefreshToken(cookie.Value))
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid refresh token")
return
}
if session.RevokedAt != nil {
writeError(w, http.StatusUnauthorized, "session revoked")
return
}
if time.Now().After(session.ExpiresAt) {
writeError(w, http.StatusUnauthorized, "session expired")
return
}
// Rotation: revoke the used session and issue a fresh pair.
if err := h.store.RevokeSession(r.Context(), session.ID); err != nil {
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
h.issueSession(w, r, session.UserID)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *apiHandler) logout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(refreshCookieName); err == nil {
session, err := h.store.GetSessionByRefreshHash(r.Context(), auth.HashRefreshToken(cookie.Value))
if err == nil {
_ = h.store.RevokeSession(r.Context(), session.ID)
}
}
clearAuthCookies(w)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *apiHandler) me(w http.ResponseWriter, r *http.Request) {
userID, ok := userIDFrom(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthenticated")
return
}
user, err := h.store.GetUserByID(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
writeJSON(w, http.StatusOK, toUserJSON(user))
}
func (h *apiHandler) issueSession(w http.ResponseWriter, r *http.Request, userID int64) {
access, err := h.tokens.IssueAccessToken(userID, accessTTL)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
rawRefresh, refreshHash, err := auth.GenerateRefreshToken()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
_, err = h.store.CreateSession(r.Context(), userID, refreshHash, r.UserAgent(), r.RemoteAddr, time.Now().Add(refreshTTL))
if err != nil {
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
setAuthCookies(w, access, rawRefresh, h.secure)
}
+93
View File
@@ -0,0 +1,93 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"messenger/internal/store"
)
func (h *apiHandler) searchUsers(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
users, err := h.store.SearchUsers(r.Context(), query, 20)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
out := make([]userJSON, 0, len(users))
for i := range users {
out = append(out, toUserJSON(&users[i]))
}
writeJSON(w, http.StatusOK, out)
}
type contactJSON struct {
User userJSON `json:"user"`
AddedAt string `json:"added_at"`
}
func (h *apiHandler) listContacts(w http.ResponseWriter, r *http.Request) {
userID, _ := userIDFrom(r.Context())
contacts, err := h.store.ListContacts(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
out := make([]contactJSON, 0, len(contacts))
for _, c := range contacts {
u, err := h.store.GetUserByID(r.Context(), c.ContactID)
if err != nil {
continue
}
out = append(out, contactJSON{User: toUserJSON(u), AddedAt: c.CreatedAt.Format("2006-01-02T15:04:05Z07:00")})
}
writeJSON(w, http.StatusOK, out)
}
func (h *apiHandler) addContact(w http.ResponseWriter, r *http.Request) {
userID, _ := userIDFrom(r.Context())
var req struct {
UserID int64 `json:"user_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID <= 0 {
writeError(w, http.StatusBadRequest, "invalid user_id")
return
}
if req.UserID == userID {
writeError(w, http.StatusBadRequest, "cannot add yourself")
return
}
if _, err := h.store.GetUserByID(r.Context(), req.UserID); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "user not found")
return
}
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
if err := h.store.AddContact(r.Context(), userID, req.UserID); err != nil {
if errors.Is(err, store.ErrDuplicate) {
writeJSON(w, http.StatusOK, map[string]string{"status": "already a contact"})
return
}
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
writeJSON(w, http.StatusCreated, map[string]string{"status": "ok"})
}
func (h *apiHandler) removeContact(w http.ResponseWriter, r *http.Request) {
userID, _ := userIDFrom(r.Context())
contactID, err := strconv.ParseInt(r.PathValue("user_id"), 10, 64)
if err != nil || contactID <= 0 {
writeError(w, http.StatusBadRequest, "invalid user_id")
return
}
if err := h.store.RemoveContact(r.Context(), userID, contactID); err != nil {
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
+172
View File
@@ -0,0 +1,172 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"time"
"messenger/internal/realtime"
"messenger/internal/store"
)
type conversationJSON struct {
ID int64 `json:"id"`
Type string `json:"type"`
CreatedAt time.Time `json:"created_at"`
LastMessage *messageJSON `json:"last_message,omitempty"`
UnreadCount int64 `json:"unread_count"`
}
type messageJSON struct {
ID int64 `json:"id"`
ConversationID int64 `json:"conversation_id"`
SenderID int64 `json:"sender_id"`
Body string `json:"body"`
CreatedAt time.Time `json:"created_at"`
EditedAt *time.Time `json:"edited_at,omitempty"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
func toMessageJSON(m *store.Message) messageJSON {
return messageJSON{
ID: m.ID,
ConversationID: m.ConversationID,
SenderID: m.SenderID,
Body: m.Body,
CreatedAt: m.CreatedAt,
EditedAt: m.EditedAt,
DeletedAt: m.DeletedAt,
}
}
func (h *apiHandler) listConversations(w http.ResponseWriter, r *http.Request) {
userID, _ := userIDFrom(r.Context())
summaries, err := h.store.ListConversationsForUser(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
out := make([]conversationJSON, 0, len(summaries))
for _, s := range summaries {
item := conversationJSON{
ID: s.ID,
Type: s.Type,
CreatedAt: s.CreatedAt,
UnreadCount: s.UnreadCount,
}
if s.LastMessage != nil {
msg := toMessageJSON(s.LastMessage)
item.LastMessage = &msg
}
out = append(out, item)
}
writeJSON(w, http.StatusOK, out)
}
func (h *apiHandler) createDM(w http.ResponseWriter, r *http.Request) {
userID, _ := userIDFrom(r.Context())
var req struct {
UserID int64 `json:"user_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID <= 0 {
writeError(w, http.StatusBadRequest, "invalid user_id")
return
}
if req.UserID == userID {
writeError(w, http.StatusBadRequest, "cannot chat with yourself")
return
}
conv, err := h.store.CreateDM(r.Context(), userID, req.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
writeJSON(w, http.StatusCreated, conversationJSON{ID: conv.ID, Type: conv.Type, CreatedAt: conv.CreatedAt})
}
func (h *apiHandler) listMessages(w http.ResponseWriter, r *http.Request) {
userID, _ := userIDFrom(r.Context())
convID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || convID <= 0 {
writeError(w, http.StatusBadRequest, "invalid conversation id")
return
}
member, err := h.store.IsMember(r.Context(), convID, userID)
if err != nil || !member {
writeError(w, http.StatusForbidden, "not a member")
return
}
limit := 50
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 100 {
limit = n
}
}
var before int64
if v := r.URL.Query().Get("before"); v != "" {
before, _ = strconv.ParseInt(v, 10, 64)
}
messages, err := h.store.ListMessages(r.Context(), convID, before, limit)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
out := make([]messageJSON, 0, len(messages))
for i := range messages {
out = append(out, toMessageJSON(&messages[i]))
}
writeJSON(w, http.StatusOK, out)
}
type sendMessageRequest struct {
Body string `json:"body"`
TempID string `json:"temp_id"`
}
func (h *apiHandler) sendMessage(w http.ResponseWriter, r *http.Request) {
userID, _ := userIDFrom(r.Context())
convID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || convID <= 0 {
writeError(w, http.StatusBadRequest, "invalid conversation id")
return
}
member, err := h.store.IsMember(r.Context(), convID, userID)
if err != nil || !member {
writeError(w, http.StatusForbidden, "not a member")
return
}
var req sendMessageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if req.Body == "" {
writeError(w, http.StatusBadRequest, "body is required")
return
}
if len(req.Body) > 4000 {
writeError(w, http.StatusBadRequest, "body too long (max 4000)")
return
}
msg, err := h.store.InsertMessage(r.Context(), convID, userID, req.Body)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
h.hub.PublishMessageCreated(convID, realtime.MessageCreated{
TempID: req.TempID,
ConversationID: convID,
MessageID: msg.ID,
SenderID: userID,
Body: msg.Body,
CreatedAt: msg.CreatedAt,
})
writeJSON(w, http.StatusCreated, toMessageJSON(msg))
}
+74
View File
@@ -0,0 +1,74 @@
package api
import (
"context"
"net/http"
"time"
"messenger/internal/auth"
)
const (
accessCookieName = "messenger_access"
refreshCookieName = "messenger_refresh"
accessTTL = 15 * time.Minute
refreshTTL = 30 * 24 * time.Hour
)
type ctxKey string
// ctxKeyUserID is the context key carrying the authenticated user ID.
const ctxKeyUserID ctxKey = "messenger.userID"
func userIDFrom(ctx context.Context) (int64, bool) {
id, ok := ctx.Value(ctxKeyUserID).(int64)
return id, ok
}
func withUserID(ctx context.Context, userID int64) context.Context {
return context.WithValue(ctx, ctxKeyUserID, userID)
}
// requireAuth parses the access-token cookie and injects the user ID
// into the request context.
func requireAuth(tm *auth.TokenManager, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(accessCookieName)
if err != nil {
writeError(w, http.StatusUnauthorized, "missing access token")
return
}
userID, err := tm.ParseAccessToken(cookie.Value)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid access token")
return
}
next(w, r.WithContext(withUserID(r.Context(), userID)))
}
}
func setAuthCookies(w http.ResponseWriter, accessToken, refreshToken string, secure bool) {
http.SetCookie(w, &http.Cookie{
Name: accessCookieName,
Value: accessToken,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
MaxAge: int(accessTTL.Seconds()),
})
http.SetCookie(w, &http.Cookie{
Name: refreshCookieName,
Value: refreshToken,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
MaxAge: int(refreshTTL.Seconds()),
})
}
func clearAuthCookies(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{Name: accessCookieName, Path: "/", MaxAge: -1})
http.SetCookie(w, &http.Cookie{Name: refreshCookieName, Path: "/", MaxAge: -1})
}
+33
View File
@@ -0,0 +1,33 @@
// Package api implements the HTTP handlers for the messenger REST API.
package api
import (
"encoding/json"
"errors"
"net/http"
"messenger/internal/store"
)
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(body); err != nil {
http.Error(w, `{"error":"failed to encode response"}`, http.StatusInternalServerError)
}
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func writeStoreError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, store.ErrNotFound):
writeError(w, http.StatusNotFound, "not found")
case errors.Is(err, store.ErrDuplicate):
writeError(w, http.StatusConflict, "already exists")
default:
writeError(w, http.StatusInternalServerError, "internal server error")
}
}