diff --git a/model/attachment.go b/model/attachment.go new file mode 100644 index 0000000..6714504 --- /dev/null +++ b/model/attachment.go @@ -0,0 +1,57 @@ +package model + +import ( + "bufio" + "io" + "os" + "path" + + "github.com/google/uuid" +) + +type AttachmentStore struct { + basePath string +} + +func NewAttachmentStore(basePath string) AttachmentStore { + _, err := os.Stat(basePath) + if os.IsNotExist(err) { + os.MkdirAll(basePath, 0755) + } + + return AttachmentStore{ + basePath: basePath, + } +} + +func (s *AttachmentStore) FetchAttachment(guid string) (io.Reader, error) { + fullPath := path.Join(s.basePath, guid) + f, err := os.Open(fullPath) + if err != nil { + return nil, err + } + + return bufio.NewReader(f), nil +} + +func (s *AttachmentStore) StoreAttachment(filename string, reader io.Reader) (*string, error) { + // Generate GUID + guid := uuid.New().String() + + fullPath := path.Join(s.basePath, guid) + f, err := os.OpenFile(fullPath, os.O_CREATE|os.O_WRONLY, 0755) + if err != nil { + return nil, err + } + + r := bufio.NewReader(reader) + w := bufio.NewWriter(f) + _, err = w.ReadFrom(r) + + return &guid, err +} + +func (s *AttachmentStore) DeleteAttachment(guid string) error { + fullPath := path.Join(s.basePath, guid) + return os.Remove(fullPath) +} diff --git a/server/server.go b/server/server.go index b49c9f0..5f4cfd1 100644 --- a/server/server.go +++ b/server/server.go @@ -1,6 +1,9 @@ package server import ( + "io" + "os" + "path" "sort" "code.severnaya.net/kordophone-mock/v2/data" @@ -16,13 +19,14 @@ const ( ) type Server struct { - version string - conversations []model.Conversation - authTokens []model.AuthToken - messageStore map[string][]model.Message - updateItems map[int]model.UpdateItem - updateChannels []chan []model.UpdateItem - updateItemSeq int + version string + conversations []model.Conversation + authTokens []model.AuthToken + attachmentStore model.AttachmentStore + messageStore map[string][]model.Message + updateItems map[int]model.UpdateItem + updateChannels []chan []model.UpdateItem + updateItemSeq int } type MessagesQuery struct { @@ -50,14 +54,17 @@ func (e *DatabaseError) Error() string { } func NewServer() *Server { + attachmentStorePath := path.Join(os.TempDir(), "kpmock", "attachments") + return &Server{ - version: VERSION, - conversations: []model.Conversation{}, - authTokens: []model.AuthToken{}, - messageStore: make(map[string][]model.Message), - updateItems: make(map[int]model.UpdateItem), - updateChannels: []chan []model.UpdateItem{}, - updateItemSeq: 0, + version: VERSION, + conversations: []model.Conversation{}, + authTokens: []model.AuthToken{}, + attachmentStore: model.NewAttachmentStore(attachmentStorePath), + messageStore: make(map[string][]model.Message), + updateItems: make(map[int]model.UpdateItem), + updateChannels: []chan []model.UpdateItem{}, + updateItemSeq: 0, } } @@ -287,6 +294,18 @@ func (s *Server) MarkConversationAsRead(conversation *model.Conversation) { }) } +func (s *Server) FetchAttachment(guid string) (io.Reader, error) { + return s.attachmentStore.FetchAttachment(guid) +} + +func (s *Server) UploadAttachment(filename string, reader io.Reader) (*string, error) { + return s.attachmentStore.StoreAttachment(filename, reader) +} + +func (s *Server) DeleteAttachment(guid string) error { + return s.attachmentStore.DeleteAttachment(guid) +} + // Private func (s *Server) registerAuthToken(token *model.AuthToken) { diff --git a/web/response_types.go b/web/response_types.go index efb3895..dfab340 100644 --- a/web/response_types.go +++ b/web/response_types.go @@ -1 +1,5 @@ package web + +type UploadAttachmentResponse struct { + TransferGUID string `json:"fileTransferGUID"` +} diff --git a/web/server.go b/web/server.go index 832e5bc..da15336 100644 --- a/web/server.go +++ b/web/server.go @@ -1,6 +1,7 @@ package web import ( + "bufio" "encoding/json" "fmt" "net/http" @@ -330,6 +331,67 @@ func (m *MockHTTPServer) handleMarkConversation(w http.ResponseWriter, r *http.R w.WriteHeader(http.StatusOK) } +func (m *MockHTTPServer) handleFetchAttachment(w http.ResponseWriter, r *http.Request) { + if !m.requireAuthentication(w, r) { + return + } + + guid := r.URL.Query().Get("guid") + if guid == "" { + log.Error().Msg("fetchAttachment: Missing 'guid' parameter") + http.Error(w, "no guid parameter specified", http.StatusBadRequest) + return + } + + reader, err := m.Server.FetchAttachment(guid) + if err != nil { + log.Error().Msgf("fetchAttachment: Could not load attachment from store: %s", err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + dw := bufio.NewWriter(w) + _, err = dw.ReadFrom(reader) + if err != nil { + log.Error().Msgf("fetchAttachment: Error reading attachment data: %s", err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +func (m *MockHTTPServer) handleUploadAttachment(w http.ResponseWriter, r *http.Request) { + if !m.requireAuthentication(w, r) { + return + } + + filename := r.URL.Query().Get("filename") + if filename == "" { + log.Error().Msgf("uploadAttachment: filename not provided") + http.Error(w, "filename not provided", http.StatusBadRequest) + return + } + + guid, err := m.Server.UploadAttachment(filename, r.Body) + if err != nil { + log.Error().Msgf("uploadAttachment: error storing attachment: %s", err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + response := UploadAttachmentResponse{ + TransferGUID: *guid, + } + + jsonData, err := json.Marshal(response) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(jsonData) +} + func (m *MockHTTPServer) handleUpdates(w http.ResponseWriter, r *http.Request) { if !m.requireAuthentication(w, r) { return @@ -380,6 +442,8 @@ func NewMockHTTPServer(config MockHTTPServerConfiguration) *MockHTTPServer { this.mux.Handle("/pollUpdates", http.HandlerFunc(this.handlePollUpdates)) this.mux.Handle("/sendMessage", http.HandlerFunc(this.handleSendMessage)) this.mux.Handle("/markConversation", http.HandlerFunc(this.handleMarkConversation)) + this.mux.Handle("/fetchAttachment", http.HandlerFunc(this.handleFetchAttachment)) + this.mux.Handle("/uploadAttachment", http.HandlerFunc(this.handleUploadAttachment)) this.mux.Handle("/updates", http.HandlerFunc(this.handleUpdates)) this.mux.Handle("/", http.HandlerFunc(this.handleNotFound)) diff --git a/web/server_test.go b/web/server_test.go index f961d15..ca8d443 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -602,3 +602,47 @@ func TestSendMessage(t *testing.T) { t.Fatalf("Message not found in conversation") } } + +func TestAttachments(t *testing.T) { + s := web.NewMockHTTPServer(web.MockHTTPServerConfiguration{AuthEnabled: false}) + httpServer := httptest.NewServer(s) + + // Send upload request + testData := "hello world!" + attachmentDataReader := strings.NewReader(testData) + resp, err := http.Post(httpServer.URL+"/uploadAttachment?filename=test.txt", "application/data", attachmentDataReader) + if err != nil { + t.Fatalf("Error uploading attachment: %s", err) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Error decoding body: %s", body) + } + + var response web.UploadAttachmentResponse + err = json.Unmarshal(body, &response) + if err != nil { + t.Fatalf("Error decoding response: %s", err) + } + + guid := response.TransferGUID + + // Cleanup after ourselves + defer s.Server.DeleteAttachment(guid) + + // Fetch it back + resp, err = http.Get(httpServer.URL + fmt.Sprintf("/fetchAttachment?guid=%s", guid)) + if err != nil { + t.Fatalf("Error fetching attachment: %s", err) + } + + responseData, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Error reading attachment data: %s", err) + } + + if string(responseData) != testData { + t.Fatalf("Didn't get expected response data: %s (got %s)", testData, responseData) + } +}