| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461 |
- package http
- import (
- "context"
- "encoding/base64"
- "encoding/json"
- "errors"
- "fmt"
- "log/slog"
- "net"
- "net/http"
- "os"
- "strings"
- "github.com/google/uuid"
- "github.com/mk6i/retro-aim-server/config"
- "github.com/mk6i/retro-aim-server/state"
- "github.com/mk6i/retro-aim-server/wire"
- )
- func StartManagementAPI(
- cfg config.Config,
- userManager UserManager,
- sessionRetriever SessionRetriever,
- chatRoomRetriever ChatRoomRetriever,
- chatRoomCreator ChatRoomCreator,
- chatSessionRetriever ChatSessionRetriever,
- messageRelayer MessageRelayer,
- logger *slog.Logger,
- ) {
- mux := http.NewServeMux()
- mux.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) {
- userHandler(w, r, userManager, uuid.New, logger)
- })
- mux.HandleFunc("/user/password", func(w http.ResponseWriter, r *http.Request) {
- userPasswordHandler(w, r, userManager, uuid.New, logger)
- })
- mux.HandleFunc("/user/login", func(w http.ResponseWriter, r *http.Request) {
- loginHandler(w, r, userManager, logger)
- })
- mux.HandleFunc("/session", func(w http.ResponseWriter, r *http.Request) {
- sessionHandler(w, r, sessionRetriever)
- })
- mux.HandleFunc("/chat/room/public", func(w http.ResponseWriter, r *http.Request) {
- publicChatHandler(w, r, chatRoomRetriever, chatRoomCreator, chatSessionRetriever, state.NewChatRoom, logger)
- })
- mux.HandleFunc("/chat/room/private", func(w http.ResponseWriter, r *http.Request) {
- privateChatHandler(w, r, chatRoomRetriever, chatSessionRetriever, logger)
- })
- mux.HandleFunc("/instant-message", func(w http.ResponseWriter, r *http.Request) {
- instantMessageHandler(w, r, messageRelayer, logger)
- })
- addr := net.JoinHostPort(cfg.ApiHost, cfg.ApiPort)
- logger.Info("starting management API server", "addr", addr)
- if err := http.ListenAndServe(addr, mux); err != nil {
- logger.Error("unable to bind management API address address", "err", err.Error())
- os.Exit(1)
- }
- }
- func userHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, newUUID func() uuid.UUID, logger *slog.Logger) {
- switch r.Method {
- case http.MethodDelete:
- deleteUserHandler(w, r, userManager, logger)
- case http.MethodGet:
- getUserHandler(w, r, userManager, logger)
- case http.MethodPost:
- postUserHandler(w, r, userManager, newUUID, logger)
- default:
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- }
- }
- func deleteUserHandler(w http.ResponseWriter, r *http.Request, manager UserManager, logger *slog.Logger) {
- user, err := userFromBody(r)
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
- err = manager.DeleteUser(state.NewIdentScreenName(user.ScreenName))
- switch {
- case errors.Is(err, state.ErrNoUser):
- http.Error(w, "user does not exist", http.StatusNotFound)
- return
- case err != nil:
- logger.Error("error deleting user DELETE /user", "err", err.Error())
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- w.WriteHeader(http.StatusNoContent)
- fmt.Fprintln(w, "User account successfully deleted.")
- }
- func userPasswordHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, newUUID func() uuid.UUID, logger *slog.Logger) {
- switch r.Method {
- case http.MethodPut:
- putUserPasswordHandler(w, r, userManager, newUUID, logger)
- default:
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- }
- }
- // putUserPasswordHandler handles the PUT /user/password endpoint.
- func putUserPasswordHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, newUUID func() uuid.UUID, logger *slog.Logger) {
- input, err := userFromBody(r)
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
- user := state.User{
- AuthKey: newUUID().String(),
- IdentScreenName: state.NewIdentScreenName(input.ScreenName),
- }
- if err := user.HashPassword(input.Password); err != nil {
- logger.Error("error hashing user password in PUT /user/password", "err", err.Error())
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- if err := userManager.SetUserPassword(user); err != nil {
- switch {
- case errors.Is(err, state.ErrNoUser):
- http.Error(w, "user does not exist", http.StatusNotFound)
- return
- case err != nil:
- logger.Error("error updating user password PUT /user/password", "err", err.Error())
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- }
- w.WriteHeader(http.StatusNoContent)
- }
- // sessionHandler handles GET /session
- func sessionHandler(w http.ResponseWriter, r *http.Request, sessionRetriever SessionRetriever) {
- w.Header().Set("Content-Type", "application/json")
- if r.Method != http.MethodGet {
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- return
- }
- allUsers := sessionRetriever.AllSessions()
- ou := onlineUsers{
- Count: len(allUsers),
- Sessions: make([]userHandle, len(allUsers)),
- }
- for i, s := range allUsers {
- ou.Sessions[i] = userHandle{
- ID: s.IdentScreenName().String(),
- ScreenName: s.DisplayScreenName().String(),
- }
- }
- if err := json.NewEncoder(w).Encode(ou); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- }
- // getUserHandler handles the GET /user endpoint.
- func getUserHandler(w http.ResponseWriter, _ *http.Request, userManager UserManager, logger *slog.Logger) {
- w.Header().Set("Content-Type", "application/json")
- users, err := userManager.AllUsers()
- if err != nil {
- logger.Error("error in GET /user", "err", err.Error())
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- out := make([]userHandle, len(users))
- for i, u := range users {
- out[i] = userHandle{
- ID: u.IdentScreenName.String(),
- ScreenName: u.DisplayScreenName.String(),
- }
- }
- if err := json.NewEncoder(w).Encode(out); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- }
- // postUserHandler handles the POST /user endpoint.
- func postUserHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, newUUID func() uuid.UUID, logger *slog.Logger) {
- input, err := userFromBody(r)
- if err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
- return
- }
- sn := state.DisplayScreenName(input.ScreenName)
- user := state.User{
- AuthKey: newUUID().String(),
- DisplayScreenName: sn,
- IdentScreenName: sn.IdentScreenName(),
- }
- if err := user.HashPassword(input.Password); err != nil {
- logger.Error("error hashing user password in POST /user", "err", err.Error())
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- err = userManager.InsertUser(user)
- switch {
- case errors.Is(err, state.ErrDupUser):
- http.Error(w, "user already exists", http.StatusConflict)
- return
- case err != nil:
- logger.Error("error inserting user POST /user", "err", err.Error())
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- w.WriteHeader(http.StatusCreated)
- fmt.Fprintln(w, "User account created successfully.")
- }
- func userFromBody(r *http.Request) (userWithPassword, error) {
- user := userWithPassword{}
- if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
- return userWithPassword{}, errors.New("malformed input")
- }
- return user, nil
- }
- // loginHandler is a temporary endpoint for validating user credentials for
- // chivanet. do not rely on this endpoint, as it will be eventually removed.
- func loginHandler(w http.ResponseWriter, r *http.Request, userManager UserManager, logger *slog.Logger) {
- authHeader := r.Header.Get("Authorization")
- if authHeader == "" {
- // No authentication header found
- w.WriteHeader(http.StatusUnauthorized)
- w.Header().Set("WWW-Authenticate", `Basic realm="User Login"`)
- w.Write([]byte("401 Unauthorized\n"))
- return
- }
- auth := strings.SplitN(authHeader, " ", 2)
- if len(auth) != 2 || auth[0] != "Basic" {
- w.WriteHeader(http.StatusUnauthorized)
- w.Write([]byte("401 Unauthorized: Missing Basic prefix\n"))
- return
- }
- payload, err := base64.StdEncoding.DecodeString(auth[1])
- if err != nil {
- w.WriteHeader(http.StatusUnauthorized)
- w.Write([]byte("401 Unauthorized: Invalid Base64 Encoding\n"))
- return
- }
- pair := strings.SplitN(string(payload), ":", 2)
- if len(pair) != 2 {
- w.WriteHeader(http.StatusUnauthorized)
- w.Write([]byte("401 Unauthorized: Invalid Authentication Token\n"))
- return
- }
- username, password := state.NewIdentScreenName(pair[0]), pair[1]
- user, err := userManager.User(username)
- if err != nil {
- w.WriteHeader(http.StatusInternalServerError)
- w.Write([]byte("500 InternalServerError\n"))
- logger.Error("error getting user", "err", err.Error())
- return
- }
- if user == nil || !user.ValidateHash(wire.StrongMD5PasswordHash(password, user.AuthKey)) {
- w.WriteHeader(http.StatusUnauthorized)
- w.Write([]byte("401 Unauthorized: Invalid Credentials\n"))
- return
- }
- // Successfully authenticated
- w.WriteHeader(http.StatusOK)
- w.Write([]byte("200 OK: Successfully Authenticated\n"))
- }
- func publicChatHandler(w http.ResponseWriter, r *http.Request, chatRoomRetriever ChatRoomRetriever, chatRoomCreator ChatRoomCreator, chatSessionRetriever ChatSessionRetriever, newChatRoom func() state.ChatRoom, logger *slog.Logger) {
- switch r.Method {
- case http.MethodGet:
- getPublicChatHandler(w, r, chatRoomRetriever, chatSessionRetriever, logger)
- case http.MethodPost:
- postPublicChatHandler(w, r, chatRoomCreator, newChatRoom, logger)
- default:
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- }
- }
- func privateChatHandler(w http.ResponseWriter, r *http.Request, chatRoomRetriever ChatRoomRetriever, chatSessionRetriever ChatSessionRetriever, logger *slog.Logger) {
- switch r.Method {
- case http.MethodGet:
- getPrivateChatHandler(w, r, chatRoomRetriever, chatSessionRetriever, logger)
- default:
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- }
- }
- // getPublicChatHandler handles the GET /chat/room/public endpoint.
- func getPublicChatHandler(w http.ResponseWriter, _ *http.Request, chatRoomRetriever ChatRoomRetriever, chatSessionRetriever ChatSessionRetriever, logger *slog.Logger) {
- w.Header().Set("Content-Type", "application/json")
- rooms, err := chatRoomRetriever.AllChatRooms(state.PublicExchange)
- if err != nil {
- logger.Error("error in GET /chat/rooms/public", "err", err.Error())
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- out := make([]chatRoom, len(rooms))
- for i, room := range rooms {
- sessions := chatSessionRetriever.AllSessions(room.Cookie)
- cr := chatRoom{
- CreateTime: room.CreateTime,
- Name: room.Name,
- Participants: make([]userHandle, len(sessions)),
- URL: room.URL().String(),
- }
- for j, sess := range sessions {
- cr.Participants[j] = userHandle{
- ID: sess.IdentScreenName().String(),
- ScreenName: sess.DisplayScreenName().String(),
- }
- }
- out[i] = cr
- }
- if err := json.NewEncoder(w).Encode(out); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- }
- // postPublicChatHandler handles the POST /chat/room/public endpoint.
- func postPublicChatHandler(w http.ResponseWriter, r *http.Request, chatRoomCreator ChatRoomCreator, newChatRoom func() state.ChatRoom, logger *slog.Logger) {
- input := chatRoomCreate{}
- if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
- http.Error(w, "invalid input", http.StatusBadRequest)
- return
- }
- input.Name = strings.TrimSpace(input.Name)
- if input.Name == "" || len(input.Name) > 50 {
- http.Error(w, "chat room name must be between 1 and 50 characters", http.StatusBadRequest)
- return
- }
- cr := newChatRoom()
- cr.Name = input.Name
- cr.Exchange = state.PublicExchange
- err := chatRoomCreator.CreateChatRoom(cr)
- switch {
- case errors.Is(err, state.ErrDupChatRoom):
- http.Error(w, "Chat room already exists.", http.StatusConflict)
- return
- case err != nil:
- logger.Error("error inserting chat room POST /chat/room/public", "err", err.Error())
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- w.WriteHeader(http.StatusCreated)
- fmt.Fprintln(w, "Chat room created successfully.")
- }
- // getPrivateChatHandler handles the GET /chat/room/private endpoint.
- func getPrivateChatHandler(w http.ResponseWriter, _ *http.Request, chatRoomRetriever ChatRoomRetriever, chatSessionRetriever ChatSessionRetriever, logger *slog.Logger) {
- w.Header().Set("Content-Type", "application/json")
- rooms, err := chatRoomRetriever.AllChatRooms(state.PrivateExchange)
- if err != nil {
- logger.Error("error in GET /chat/rooms/private", "err", err.Error())
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- out := make([]chatRoom, len(rooms))
- for i, room := range rooms {
- sessions := chatSessionRetriever.AllSessions(room.Cookie)
- cr := chatRoom{
- CreateTime: room.CreateTime,
- CreatorID: room.Creator.String(),
- Name: room.Name,
- Participants: make([]userHandle, len(sessions)),
- URL: room.URL().String(),
- }
- for j, sess := range sessions {
- cr.Participants[j] = userHandle{
- ID: sess.IdentScreenName().String(),
- ScreenName: sess.DisplayScreenName().String(),
- }
- }
- out[i] = cr
- }
- if err := json.NewEncoder(w).Encode(out); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- }
- func instantMessageHandler(w http.ResponseWriter, r *http.Request, messageRelayer MessageRelayer, logger *slog.Logger) {
- switch r.Method {
- case http.MethodPost:
- postInstantMessageHandler(w, r, messageRelayer, logger)
- default:
- http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
- }
- }
- // postIMHandler handles the POST /instant-message endpoint.
- func postInstantMessageHandler(w http.ResponseWriter, r *http.Request, messageRelayer MessageRelayer, logger *slog.Logger) {
- input := instantMessage{}
- if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
- http.Error(w, "malformed input", http.StatusBadRequest)
- return
- }
- tlv, err := wire.ICBMFragmentList(input.Text)
- if err != nil {
- logger.Error("error sending message POST /instant-message", "err", err.Error())
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- msg := wire.SNACMessage{
- Frame: wire.SNACFrame{
- FoodGroup: wire.ICBM,
- SubGroup: wire.ICBMChannelMsgToClient,
- },
- Body: wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
- ChannelID: 1,
- TLVUserInfo: wire.TLVUserInfo{
- ScreenName: input.From,
- },
- TLVRestBlock: wire.TLVRestBlock{
- TLVList: wire.TLVList{
- wire.NewTLV(wire.ICBMTLVAOLIMData, tlv),
- },
- },
- },
- }
- messageRelayer.RelayToScreenName(context.Background(), state.NewIdentScreenName(input.To), msg)
- w.WriteHeader(http.StatusOK)
- fmt.Fprintln(w, "Message sent successfully.")
- }
|