diff --git a/README.md b/README.md index ae75317..023369a 100644 --- a/README.md +++ b/README.md @@ -75,14 +75,16 @@ Implemented APIs: Limitations: - when a private room is deleted, there is no way to send messages to the user -- text-only messages are supported (no media, no rich content) - only one-to-one direct messaging is supported (no group chats) - before using the chat from the app, users who receive or send must have logged into Matrix at least once using an official client (Cinny or Element) +- sending images from Acrobits to Matrix is not yet supported (only receiving) The following features are not yet implemented: - Account removal: https://doc.acrobits.net/api/client/account_removal_reporter.html#account-removal-reporter-webservice -- Messages with media content: +- Sending messages with media content from Acrobits to Matrix +- Other media types (video, audio, files) +- Rich message features: - https://doc.acrobits.net/api/client/x-acro-filetransfer.html - https://doc.acrobits.net/api/client/decryption.html - https://doc.acrobits.net/mmmsg/index.html diff --git a/go.mod b/go.mod index 111ad41..4b29f3d 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,9 @@ module github.com/nethesis/matrix2acrobits go 1.24.10 require ( + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/labstack/echo/v4 v4.13.4 + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 github.com/rs/zerolog v1.34.0 github.com/stretchr/testify v1.11.1 maunium.net/go/mautrix v0.26.0 @@ -14,7 +16,6 @@ require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/labstack/gommon v0.4.2 // indirect diff --git a/go.sum b/go.sum index 63acd4f..ccc5ee9 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/main_test.go b/main_test.go index 4a57bd9..4dfcb65 100644 --- a/main_test.go +++ b/main_test.go @@ -3,10 +3,16 @@ package main import ( "bytes" "context" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "io" "net/http" + "net/http/httptest" + "net/url" "os" "strings" "testing" @@ -23,7 +29,6 @@ import ( "maunium.net/go/mautrix/id" ) -const testEnvFile = "test/test.env" const testServerPort = "18080" type testConfig struct { @@ -119,12 +124,13 @@ func startTestServer(cfg *testConfig) (*echo.Echo, error) { MatrixAsUserID: id.UserID(cfg.asUser), MatrixHomeserverHost: cfg.serverName, PushTokenDBPath: "/tmp/push_tokens_test.db", - ProxyURL: cfg.homeserverURL, + ProxyURL: "http://127.0.0.1:18080", CacheTTLSeconds: 3600, CacheTTL: 3600 * time.Second, ExtAuthURL: "http://localhost:18081", ExtAuthTimeoutS: 5, ExtAuthTimeout: 5 * time.Second, + MMMSGURL: os.Getenv("MMMSG_URL"), } // Initialize push token database @@ -207,6 +213,13 @@ func doRequest(method, url string, body interface{}, headers map[string]string) // the response parses successfully or the timeout elapses. It returns the last // parsed response (may be empty) and any final error. func fetchMessagesWithRetry(t *testing.T, baseURL, username string, timeout time.Duration) (models.FetchMessagesResponse, error) { + t.Helper() + return fetchMessagesWithRetryAndPassword(t, baseURL, username, "", timeout) +} + +// fetchMessagesWithRetryAndPassword calls the proxy fetch_messages endpoint repeatedly until +// the response parses successfully or the timeout elapses. It accepts an optional password. +func fetchMessagesWithRetryAndPassword(t *testing.T, baseURL, username, password string, timeout time.Duration) (models.FetchMessagesResponse, error) { t.Helper() deadline := time.Now().Add(timeout) var lastResp models.FetchMessagesResponse @@ -214,6 +227,7 @@ func fetchMessagesWithRetry(t *testing.T, baseURL, username string, timeout time for time.Now().Before(deadline) { fetchReq := models.FetchMessagesRequest{ Username: username, + Password: password, LastID: "", } resp, body, err := doRequest("POST", baseURL+"/api/client/fetch_messages", fetchReq, nil) @@ -240,42 +254,6 @@ func fetchMessagesWithRetry(t *testing.T, baseURL, username string, timeout time return lastResp, lastErr } -// generateMappingVariants returns likely variants for a phone number mapping key. -func generateMappingVariants(s string) []string { - out := make([]string, 0, 4) - trimmed := strings.TrimSpace(s) - if trimmed == "" { - return out - } - out = append(out, trimmed) - // if starts with +, add without + - if strings.HasPrefix(trimmed, "+") { - out = append(out, strings.TrimPrefix(trimmed, "+")) - } - // digits-only - digits := make([]rune, 0, len(trimmed)) - for _, r := range trimmed { - if r >= '0' && r <= '9' { - digits = append(digits, r) - } - } - if len(digits) > 0 { - digitsOnly := string(digits) - if digitsOnly != trimmed { - out = append(out, digitsOnly) - } - } - return out -} - -// Helper to get localpart from username like `user@domain.com` -func getLocalpart(username string) string { - if idx := strings.Index(username, "@"); idx != -1 { - return username[:idx] - } - return username -} - func TestIntegration_PushTokenReport(t *testing.T) { cfg := checkTestEnv(t) @@ -524,3 +502,544 @@ func TestIntegration_SendAndFetchMessages(t *testing.T) { } }) } + +func TestIntegration_SendAndFetchImageMessages(t *testing.T) { + cfg := checkTestEnv(t) + + // This test runs against a live homeserver defined in test.env + // It requires the homeserver to be configured with the Application Service. + if os.Getenv("RUN_INTEGRATION_TESTS") == "" { + t.Skip("Skipping integration tests; set RUN_INTEGRATION_TESTS=1 to run.") + } + + // Start mock MMMSG server + uploadedByPath := make(map[string][]byte) + var uploadCounter int + mmmsgServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" { + uploadCounter++ + uploadPath := fmt.Sprintf("/upload/%d", uploadCounter) + t.Logf("Mock MMMSG: received POST request, returning upload path %s", uploadPath) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "url": "http://" + r.Host + uploadPath, + }) + return + } + if r.Method == "PUT" && strings.HasPrefix(r.URL.Path, "/upload/") { + t.Logf("Mock MMMSG: received PUT request for %s", r.URL.Path) + data, _ := io.ReadAll(r.Body) + uploadedByPath[r.URL.Path] = data + w.WriteHeader(http.StatusOK) + return + } + t.Logf("Mock MMMSG: received unexpected %s request for %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + })) + defer mmmsgServer.Close() + + // Set MMMSG_URL to point to our mock server + os.Setenv("MMMSG_URL", mmmsgServer.URL) + defer os.Unsetenv("MMMSG_URL") + + server, err := startTestServer(cfg) + if err != nil { + t.Fatalf("failed to start test server: %v", err) + } + defer stopTestServer(server) + + baseURL := "http://127.0.0.1:" + testServerPort + + // Load the test image + imageData, err := os.ReadFile("test/test.jpg") + if err != nil { + t.Fatalf("failed to read test/test.jpg: %v", err) + } + t.Logf("Loaded test image: %d bytes", len(imageData)) + + // Step 1: Send image from USER1 to USER2 using Matrix API + t.Run("SendImageViaMatrix", func(t *testing.T) { + // Create a Matrix client for user1 + user1MatrixID := id.UserID(cfg.user1) + + uploadResp, err := uploadImageToMatrix(t, cfg, imageData) + if err != nil { + t.Fatalf("failed to upload image: %v", err) + } + t.Logf("Image uploaded to Matrix: %s", uploadResp) + + // Create a room between user1 and user2 and send the image + roomID, err := getOrCreateDirectRoom(t, cfg, cfg.user1, cfg.user2) + if err != nil { + t.Fatalf("failed to get/create direct room: %v", err) + } + t.Logf("Direct room created/found: %s", roomID) + + // Join the room as user1 (as admin to ensure it works) + err = joinRoomAsUser(t, cfg, cfg.user1, roomID) + if err != nil { + t.Logf("warning: failed to join room as user1: %v", err) + } else { + t.Logf("Successfully joined room as user1") + } + + // Join the room as user2 so they can receive the message + // First, get Mario's access token by logging in + user2Token, err := loginUser(t, cfg, strings.Split(cfg.user2, "@")[0], cfg.user2Password) + if err != nil { + t.Logf("warning: failed to get user2 access token: %v", err) + } else { + t.Logf("Got user2 access token") + + err = joinRoomAsUserWithToken(t, cfg, user2Token, roomID) + if err != nil { + t.Fatalf("failed to join room as user2: %v", err) + } + t.Logf("Successfully joined room as user2 with their own token") + } + + // Also try with admin token as backup + err = joinRoomAsUser(t, cfg, cfg.user2, roomID) + if err != nil { + t.Logf("warning: failed to join room as user2 with admin: %v", err) + } else { + t.Logf("Also joined room as user2 with admin token") + } + + // First send a test text message to verify the room is working + testResp := map[string]interface{}{ + "msgtype": "m.text", + "body": "Test text message before image", + } + textURL := fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message", cfg.homeserverURL, roomID) + textBody, _ := json.Marshal(testResp) + textReq, _ := http.NewRequest("POST", textURL, bytes.NewReader(textBody)) + textReq.Header.Set("Content-Type", "application/json") + textReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.adminToken)) + textClient := http.Client{Timeout: 10 * time.Second} + textHttpResp, _ := textClient.Do(textReq) + textHttpResp.Body.Close() + t.Logf("Sent test text message to room") + + // Send image message using Matrix API + eventID, err := sendImageMessage(t, cfg, user1MatrixID, roomID, uploadResp, "Test image message") + if err != nil { + t.Fatalf("failed to send image message: %v", err) + } + t.Logf("Image message sent successfully to room %s with event ID: %s", roomID, eventID) + }) + + // Step 2: Fetch messages as USER2 and verify image attachment + t.Run("FetchImageAndVerify", func(t *testing.T) { + // Give the server a moment to process the message + time.Sleep(500 * time.Millisecond) + + // Try multiple fetches until we see the image message or timeout + var imageMsg *models.SMS + for attempts := 0; attempts < 5; attempts++ { + fetchResp, err := fetchMessagesWithRetryAndPassword(t, baseURL, cfg.user2, cfg.user2Password, 10*time.Second) + if err != nil { + t.Logf("Attempt %d: fetch messages failed: %v", attempts+1, err) + time.Sleep(200 * time.Millisecond) + continue + } + + // Log attempt info + t.Logf("Attempt %d: Total received messages: %d", attempts+1, len(fetchResp.ReceivedSMSs)) + + // Find the image or text message from our test + for i := range fetchResp.ReceivedSMSs { + msg := &fetchResp.ReceivedSMSs[i] + if msg.ContentType == "application/x-acro-filetransfer+json" { + t.Logf("Attempt %d: Found image message with content type %s", attempts+1, msg.ContentType) + imageMsg = msg + break + } + if msg.SMSText == "Test text message before image" || msg.SMSText == "Test image message" { + t.Logf("Attempt %d: Found test message (text): %s", attempts+1, msg.SMSText) + } + } + + if imageMsg != nil { + break + } + + // Sleep before retry + if attempts < 4 { + time.Sleep(300 * time.Millisecond) + } + } + + if imageMsg == nil { + t.Fatalf("no image message found in received messages after multiple attempts") + } + + // Verify attachment structure + var ft models.FileTransfer + err = json.Unmarshal([]byte(imageMsg.SMSText), &ft) + if err != nil { + t.Fatalf("failed to unmarshal SMSText as FileTransfer: %v. SMSText: %s", err, imageMsg.SMSText) + } + + if len(ft.Attachments) == 0 { + t.Fatalf("image message has no attachments in FileTransfer") + } + + attachment := ft.Attachments[0] + t.Logf("Attachment content-type: %s, url: %s, size: %d", attachment.ContentType, attachment.ContentURL, attachment.ContentSize) + + if attachment.ContentType == "" { + t.Errorf("attachment content-type is empty") + } + + if attachment.ContentURL == "" { + t.Errorf("attachment content-url is empty") + } + + if attachment.ContentSize == 0 { + t.Errorf("attachment content-size is 0") + } + + // Verify preview exists + if attachment.Preview == nil || attachment.Preview.Content == "" { + t.Errorf("attachment preview is missing or empty") + } + + // Step 3: Download the image and verify it matches + t.Run("VerifyImagePreview", func(t *testing.T) { + // Verify that the attachment has a preview with the correct structure + if ft.Attachments[0].Preview == nil { + t.Fatalf("attachment has no preview") + } + + preview := ft.Attachments[0].Preview + if preview.ContentType != "image/jpeg" { + t.Errorf("preview content-type is %s, expected image/jpeg", preview.ContentType) + } + + if preview.Content == "" { + t.Errorf("preview content is empty") + } + + // Decode the base64 preview + decodedPreview, err := base64.StdEncoding.DecodeString(preview.Content) + if err != nil { + t.Errorf("failed to decode preview: %v", err) + } else if len(decodedPreview) == 0 { + t.Errorf("decoded preview is empty") + } else { + t.Logf("Preview decoded successfully: %d bytes", len(decodedPreview)) + } + + // Verify the attachment structure + t.Logf("Attachment structure verified: ContentType=%s, ContentURL=%s, ContentSize=%d, HasPreview=%v", + ft.Attachments[0].ContentType, + ft.Attachments[0].ContentURL, + ft.Attachments[0].ContentSize, + ft.Attachments[0].Preview != nil) + }) + + // Step 4: Verify MMMSG upload and encryption + t.Run("VerifyMMMSGUpload", func(t *testing.T) { + // Locate the uploaded data for this attachment by using the content URL path + parsed, perr := url.Parse(attachment.ContentURL) + if perr != nil { + t.Fatalf("failed to parse attachment content URL: %v", perr) + } + uploadedData, ok := uploadedByPath[parsed.Path] + if !ok || len(uploadedData) == 0 { + t.Fatalf("no data was uploaded to MMMSG for path %s", parsed.Path) + } + t.Logf("Data uploaded to MMMSG: %d bytes (path=%s)", len(uploadedData), parsed.Path) + + if !strings.HasPrefix(attachment.ContentURL, mmmsgServer.URL) { + t.Errorf("attachment content-url %s does not point to mock MMMSG server %s", attachment.ContentURL, mmmsgServer.URL) + } + + if attachment.EncryptionKey == "" { + t.Errorf("encryption key is missing in attachment") + } else { + t.Logf("Encryption key found: %s", attachment.EncryptionKey) + + // Verify that uploaded data is different from original (encrypted) + if bytes.Equal(uploadedData, imageData) { + t.Errorf("uploaded data is not encrypted (matches original)") + } else { + t.Logf("Uploaded data is encrypted (differs from original)") + } + + // Try to decrypt and verify + key, _ := hex.DecodeString(attachment.EncryptionKey) + block, _ := aes.NewCipher(key) + iv := make([]byte, aes.BlockSize) + stream := cipher.NewCTR(block, iv) + decrypted := make([]byte, len(uploadedData)) + stream.XORKeyStream(decrypted, uploadedData) + + if !bytes.Equal(decrypted, imageData) { + t.Errorf("decrypted data does not match original image data") + } else { + t.Logf("Decrypted data matches original image data successfully") + } + } + }) + }) +} + +// uploadImageToMatrix uploads an image to the Matrix media repository +// and returns the mxc:// URL +func uploadImageToMatrix(t *testing.T, cfg *testConfig, imageData []byte) (string, error) { + t.Helper() + + client := http.Client{Timeout: 10 * time.Second} + url := fmt.Sprintf("%s/_matrix/media/v3/upload?access_token=%s", cfg.homeserverURL, cfg.adminToken) + + req, err := http.NewRequest("POST", url, bytes.NewReader(imageData)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "image/jpeg") + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to upload image: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("upload returned status %d: %s", resp.StatusCode, string(body)) + } + + var uploadResp struct { + ContentURI string `json:"content_uri"` + } + + if err := json.NewDecoder(resp.Body).Decode(&uploadResp); err != nil { + return "", fmt.Errorf("failed to parse upload response: %w", err) + } + + if uploadResp.ContentURI == "" { + return "", fmt.Errorf("upload response missing content_uri") + } + + return uploadResp.ContentURI, nil +} + +// getOrCreateDirectRoom gets or creates a direct room between two users +func getOrCreateDirectRoom(t *testing.T, cfg *testConfig, user1, user2 string) (string, error) { + t.Helper() + + client := http.Client{Timeout: 10 * time.Second} + + // Convert user IDs to proper Matrix format: extract username and add @ prefix and :homeserver + // e.g., "giacomo@localhost" -> "@giacomo:localhost" + formatMatrixUserID := func(u string) string { + parts := strings.Split(u, "@") + if len(parts) == 2 { + return "@" + parts[0] + ":" + parts[1] + } + return "@" + u + ":localhost" + } + + inviteUser := formatMatrixUserID(user2) + + // Try to find existing direct room by querying joined rooms + // For simplicity, we'll just create a new room + createReq := map[string]interface{}{ + "preset": "trusted_private_chat", + "is_direct": true, + "invite": []string{ + inviteUser, + }, + } + + url := fmt.Sprintf("%s/_matrix/client/v3/createRoom", cfg.homeserverURL) + body, _ := json.Marshal(createReq) + + req, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.adminToken)) + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to create room: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("create room returned status %d: %s", resp.StatusCode, string(respBody)) + } + + var createResp struct { + RoomID string `json:"room_id"` + } + + if err := json.NewDecoder(resp.Body).Decode(&createResp); err != nil { + return "", fmt.Errorf("failed to parse create room response: %w", err) + } + + return createResp.RoomID, nil +} + +// joinRoomAsUser joins a room as a specific user using admin API +func joinRoomAsUser(t *testing.T, cfg *testConfig, userID string, roomID string) error { + t.Helper() + + client := http.Client{Timeout: 10 * time.Second} + + url := fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/join", cfg.homeserverURL, roomID) + body := []byte("{}") + + req, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.adminToken)) + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to join room: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("join room returned status %d: %s", resp.StatusCode, string(respBody)) + } + + return nil +} + +// loginUser logs in as a user and returns their access token +func loginUser(t *testing.T, cfg *testConfig, username, password string) (string, error) { + t.Helper() + + client := http.Client{Timeout: 10 * time.Second} + + loginReq := map[string]interface{}{ + "type": "m.login.password", + "user": username, + "password": password, + } + + url := fmt.Sprintf("%s/_matrix/client/v3/login", cfg.homeserverURL) + body, _ := json.Marshal(loginReq) + + req, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("failed to create login request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("login request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("login returned status %d: %s", resp.StatusCode, string(respBody)) + } + + var loginResp struct { + AccessToken string `json:"access_token"` + } + + if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil { + return "", fmt.Errorf("failed to parse login response: %w", err) + } + + return loginResp.AccessToken, nil +} + +// joinRoomAsUserWithToken joins a room as a specific user using their access token +func joinRoomAsUserWithToken(t *testing.T, cfg *testConfig, accessToken, roomID string) error { + t.Helper() + + client := http.Client{Timeout: 10 * time.Second} + + url := fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/join", cfg.homeserverURL, roomID) + body := []byte("{}") + + req, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to join room: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("join room returned status %d: %s", resp.StatusCode, string(respBody)) + } + + return nil +} + +// sendImageMessage sends an image message to a room using Matrix API +func sendImageMessage(t *testing.T, cfg *testConfig, userID id.UserID, roomID string, mxcURL string, text string) (string, error) { + t.Helper() + + client := http.Client{Timeout: 10 * time.Second} + + // Build image message event + msgContent := map[string]interface{}{ + "msgtype": "m.image", + "url": mxcURL, + "body": text, + "info": map[string]interface{}{ + "size": 65000, // approximate size + "mimetype": "image/jpeg", + }, + } + + url := fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message", cfg.homeserverURL, roomID) + body, _ := json.Marshal(msgContent) + + req, err := http.NewRequest("POST", url, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.adminToken)) + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to send message: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("send message returned status %d: %s", resp.StatusCode, string(respBody)) + } + + var sendResp struct { + EventID string `json:"event_id"` + } + + if err := json.NewDecoder(resp.Body).Decode(&sendResp); err != nil { + return "", fmt.Errorf("failed to parse send message response: %w", err) + } + + return sendResp.EventID, nil +} diff --git a/matrix/client.go b/matrix/client.go index fd501ef..47cf67a 100644 --- a/matrix/client.go +++ b/matrix/client.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "net/http" "strings" "sync" @@ -29,7 +30,7 @@ type Config struct { // A mutex is used to make operations thread-safe. type MatrixClient struct { cli *mautrix.Client - homeserverURL string + HomeserverURL string homeserverName string mu sync.Mutex } @@ -74,7 +75,7 @@ func NewClient(cfg Config) (*MatrixClient, error) { return &MatrixClient{ cli: client, - homeserverURL: cfg.HomeserverURL, + HomeserverURL: cfg.HomeserverURL, homeserverName: homeserverName, }, nil } @@ -247,3 +248,45 @@ func (mc *MatrixClient) SetPusher(ctx context.Context, userID id.UserID, req *mo Msg("matrix: pusher set successfully") return nil } + +// DownloadMedia downloads media content from Matrix homeserver given an mxc:// URI. +// Returns the media bytes and content type, or an error if download fails. +func (mc *MatrixClient) DownloadMedia(ctx context.Context, mxcURL string) ([]byte, string, error) { + logger.Debug().Str("mxc_url", mxcURL).Msg("matrix: downloading media") + + // Parse mxc:// URL + mxc, err := id.ParseContentURI(mxcURL) + if err != nil { + logger.Error().Str("mxc_url", mxcURL).Err(err).Msg("matrix: failed to parse mxc URL") + return nil, "", fmt.Errorf("invalid mxc URL: %w", err) + } + + // Download the media + resp, err := mc.cli.Download(ctx, mxc) + if err != nil { + logger.Error().Str("mxc_url", mxcURL).Err(err).Msg("matrix: failed to download media") + return nil, "", fmt.Errorf("download media: %w", err) + } + defer resp.Body.Close() + + // Read the response body + data, err := io.ReadAll(resp.Body) + if err != nil { + logger.Error().Str("mxc_url", mxcURL).Err(err).Msg("matrix: failed to read response body") + return nil, "", fmt.Errorf("read media response: %w", err) + } + + // Get content type from response header, or detect it from data + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = http.DetectContentType(data) + } + + logger.Debug(). + Str("mxc_url", mxcURL). + Str("content_type", contentType). + Int("size", len(data)). + Msg("matrix: media downloaded successfully") + + return data, contentType, nil +} diff --git a/models/messages.go b/models/messages.go index d377124..d29c75a 100644 --- a/models/messages.go +++ b/models/messages.go @@ -44,6 +44,30 @@ type SMS struct { StreamID string `json:"stream_id"` } +// FileTransfer represents the application/x-acro-filetransfer+json format. +type FileTransfer struct { + Body string `json:"body,omitempty"` + Attachments []Attachment `json:"attachments"` +} + +// Attachment represents a file attachment in the Acrobits x-acro-filetransfer format. +type Attachment struct { + ContentType string `json:"content-type,omitempty"` + ContentURL string `json:"content-url"` + ContentSize int `json:"content-size,omitempty"` + Filename string `json:"filename,omitempty"` + Description string `json:"description,omitempty"` + EncryptionKey string `json:"encryption-key,omitempty"` + Hash string `json:"hash,omitempty"` + Preview *AttachmentPreview `json:"preview,omitempty"` +} + +// AttachmentPreview represents a low-quality preview of an attachment. +type AttachmentPreview struct { + ContentType string `json:"content-type,omitempty"` + Content string `json:"content"` // BASE64 encoded +} + // Message is a helper struct for internal use. // PushTokenReportRequest mirrors the Acrobits push token reporter POST JSON schema. diff --git a/service/config.go b/service/config.go index 138ea78..e659701 100644 --- a/service/config.go +++ b/service/config.go @@ -45,6 +45,9 @@ type Config struct { ExtAuthURL string ExtAuthTimeoutS int ExtAuthTimeout time.Duration + + // Acrobits MMMSG configuration + MMMSGURL string } // NewConfig loads all configuration from environment variables with validation @@ -155,6 +158,15 @@ func NewConfig() (*Config, error) { } cfg.ExtAuthTimeout = time.Duration(cfg.ExtAuthTimeoutS) * time.Second + // Load MMMSG configuration + cfg.MMMSGURL = os.Getenv("MMMSG_URL") + if cfg.MMMSGURL == "" { + cfg.MMMSGURL = "https://mmmsg.acrobits.net" + logger.Debug().Str("MMMSG_URL", cfg.MMMSGURL).Msg("using default MMMSG URL") + } else { + logger.Debug().Str("MMMSG_URL", cfg.MMMSGURL).Msg("MMMSG URL loaded from environment") + } + logger.Debug().Msg("configuration loading completed successfully") return cfg, nil @@ -175,6 +187,7 @@ func NewTestConfig() *Config { CacheTTL: time.Duration(defaultCacheTTLSeconds) * time.Second, ExtAuthTimeoutS: defaultExtAuthTimeoutS, ExtAuthTimeout: time.Duration(defaultExtAuthTimeoutS) * time.Second, + MMMSGURL: "https://mmmsg.acrobits.net", } } diff --git a/service/image_utils.go b/service/image_utils.go new file mode 100644 index 0000000..4e375bf --- /dev/null +++ b/service/image_utils.go @@ -0,0 +1,59 @@ +package service + +import ( + "bytes" + "encoding/base64" + "fmt" + "image" + "image/jpeg" + _ "image/png" // Register PNG decoder + + "github.com/nfnt/resize" +) + +const ( + // PreviewMaxWidth is the maximum width for image previews + PreviewMaxWidth = 120 + // PreviewMaxHeight is the maximum height for image previews + PreviewMaxHeight = 120 + // PreviewQuality is the JPEG quality for previews (lower = smaller file size) + PreviewQuality = 30 +) + +// GenerateImagePreview creates a low-quality thumbnail of an image and returns it as a base64-encoded JPEG. +// If the image cannot be decoded or resized, returns an empty string and an error. +func GenerateImagePreview(imageData []byte) (string, error) { + // Decode the image + img, format, err := image.Decode(bytes.NewReader(imageData)) + if err != nil { + return "", fmt.Errorf("failed to decode image: %w", err) + } + + // Calculate thumbnail dimensions while preserving aspect ratio + bounds := img.Bounds() + width := bounds.Dx() + height := bounds.Dy() + + var thumbWidth, thumbHeight uint + if width > height { + thumbWidth = PreviewMaxWidth + thumbHeight = 0 // resize library will calculate this to preserve aspect ratio + } else { + thumbWidth = 0 + thumbHeight = PreviewMaxHeight + } + + // Resize the image + thumbnail := resize.Resize(thumbWidth, thumbHeight, img, resize.Lanczos3) + + // Encode as JPEG with low quality + var buf bytes.Buffer + err = jpeg.Encode(&buf, thumbnail, &jpeg.Options{Quality: PreviewQuality}) + if err != nil { + return "", fmt.Errorf("failed to encode thumbnail as JPEG (original format: %s): %w", format, err) + } + + // Encode to base64 + preview := base64.StdEncoding.EncodeToString(buf.Bytes()) + return preview, nil +} diff --git a/service/image_utils_test.go b/service/image_utils_test.go new file mode 100644 index 0000000..3bd7da5 --- /dev/null +++ b/service/image_utils_test.go @@ -0,0 +1,54 @@ +package service + +import ( + "bytes" + "encoding/base64" + "image" + "image/jpeg" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGenerateImagePreview(t *testing.T) { + t.Run("valid image", func(t *testing.T) { + // Create a simple test image (100x100 red square) + img := image.NewRGBA(image.Rect(0, 0, 100, 100)) + for y := 0; y < 100; y++ { + for x := 0; x < 100; x++ { + img.Set(x, y, image.Black) + } + } + + // Encode as JPEG + var buf bytes.Buffer + err := jpeg.Encode(&buf, img, nil) + assert.NoError(t, err) + + // Generate preview + preview, err := GenerateImagePreview(buf.Bytes()) + assert.NoError(t, err) + assert.NotEmpty(t, preview) + + // Verify it's valid base64 + decoded, err := base64.StdEncoding.DecodeString(preview) + assert.NoError(t, err) + assert.NotEmpty(t, decoded) + + // Verify decoded data is a valid JPEG + _, err = jpeg.Decode(bytes.NewReader(decoded)) + assert.NoError(t, err) + }) + + t.Run("invalid image data", func(t *testing.T) { + preview, err := GenerateImagePreview([]byte("not an image")) + assert.Error(t, err) + assert.Empty(t, preview) + }) + + t.Run("empty data", func(t *testing.T) { + preview, err := GenerateImagePreview([]byte{}) + assert.Error(t, err) + assert.Empty(t, preview) + }) +} diff --git a/service/messages.go b/service/messages.go index 8570977..3c97612 100644 --- a/service/messages.go +++ b/service/messages.go @@ -1,9 +1,18 @@ package service import ( + "bytes" "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" "errors" "fmt" + "io" + "net/http" "strconv" "strings" "sync" @@ -37,6 +46,7 @@ type MessageService struct { authClient *HTTPAuthClient // Homeserver host used to build Matrix IDs from auth response homeserverHost string + mmmsgURL string mu sync.RWMutex mappings map[string]mappingEntry @@ -82,6 +92,7 @@ func NewMessageService(matrixClient *matrix.MatrixClient, pushTokenDB *db.Databa extAuthTimeout: cfg.ExtAuthTimeout, authClient: NewHTTPAuthClient(cfg.ExtAuthURL, cfg.ExtAuthTimeout, cfg.CacheTTL), homeserverHost: cfg.MatrixHomeserverHost, + mmmsgURL: cfg.MMMSGURL, } } @@ -91,21 +102,30 @@ func NewMessageService(matrixClient *matrix.MatrixClient, pushTokenDB *db.Databa func (s *MessageService) authenticateAndPersistMappings(ctx context.Context, username, password string) error { mappings, ok, err := s.authClient.Validate(ctx, username, password, s.homeserverHost) if err != nil { - if !ok { - logger.Warn().Str("username", username).Msg("external auth failed: unauthorized") - return ErrAuthentication - } - logger.Error().Err(err).Msg("external auth request failed") - return fmt.Errorf("external auth request failed: %w", err) + logger.Warn().Err(err).Str("username", username).Msg("external auth validation failed") + return ErrAuthentication + } + + if !ok { + logger.Warn().Str("username", username).Msg("external auth validation returned no access") + return ErrAuthentication } - // Persist all mappings returned by auth - for _, mapReq := range mappings { - if _, err := s.SaveMapping(mapReq); err != nil { - logger.Error().Err(err).Msg("failed to save mapping from external auth response") - return fmt.Errorf("failed to save mapping: %w", err) + // Persist mappings into local store + for _, m := range mappings { + if m == nil { + continue + } + saveReq := &models.MappingRequest{ + Number: m.Number, + MatrixID: m.MatrixID, + SubNumbers: m.SubNumbers, + } + if _, err := s.SaveMapping(saveReq); err != nil { + logger.Warn().Err(err).Int("number", m.Number).Str("matrix_id", m.MatrixID).Msg("failed to persist mapping from external auth") } } + return nil } @@ -264,10 +284,17 @@ func (s *MessageService) FetchMessages(ctx context.Context, req *models.FetchMes logger.Debug().Str("event_id", string(evt.ID)).Str("room_id", string(eventRoomID)).Msg("processing message event") + // Extract msgtype to determine message type + msgtype := "" + if mt, ok := evt.Content.Raw["msgtype"].(string); ok { + msgtype = mt + } + body := "" if b, ok := evt.Content.Raw["body"].(string); ok { body = b } + sms := models.SMS{ SMSID: string(evt.ID), SendingDate: time.UnixMilli(evt.Timestamp).UTC().Format(time.RFC3339), @@ -276,6 +303,128 @@ func (s *MessageService) FetchMessages(ctx context.Context, req *models.FetchMes StreamID: string(roomID), } + // Handle image messages + if msgtype == "m.image" { + logger.Debug().Str("event_id", string(evt.ID)).Msg("processing image message") + + // Extract image information from event content + var mxcURL string + var contentType string + + if url, ok := evt.Content.Raw["url"].(string); ok { + mxcURL = url + } + + // Get info from info object if present + if info, ok := evt.Content.Raw["info"].(map[string]interface{}); ok { + if ct, ok := info["mimetype"].(string); ok { + contentType = ct + } + } + + // Download the image and generate preview + if mxcURL != "" { + imageData, detectedType, err := s.matrixClient.DownloadMedia(ctx, mxcURL) + if err != nil { + logger.Error().Str("mxc_url", mxcURL).Err(err).Msg("failed to download image, skipping attachment") + } else { + // Use detected type if not specified in event + if contentType == "" { + contentType = detectedType + } + + // Generate preview + preview, err := GenerateImagePreview(imageData) + if err != nil { + logger.Warn().Err(err).Msg("failed to generate image preview") + } + + // Encrypt data for MMMSG + encryptedData, encryptionKey, err := encryptData(imageData) + if err != nil { + logger.Error().Err(err).Msg("failed to encrypt image data") + // Fallback to unencrypted if encryption fails? + // Better to fail or send unencrypted? + // Let's try to send unencrypted if encryption fails, but log it. + encryptedData = imageData + encryptionKey = "" + } + + // Upload to MMMSG + // Log key and a checksum for debugging encryption/upload issues + if encryptionKey != "" { + sum := sha256.Sum256(encryptedData) + sumOrig := sha256.Sum256(imageData) + logger.Debug().Str("encryption_key", encryptionKey). + Str("encrypted_sha256", hex.EncodeToString(sum[:])). + Str("original_sha256", hex.EncodeToString(sumOrig[:])). + Msg("prepared encrypted payload for upload") + + // Perform a local decryption check to ensure the key decrypts the payload correctly. + // This helps catch issues where the encrypted bytes differ from what's stored or uploaded. + keyBytes, derr := hex.DecodeString(encryptionKey) + if derr == nil { + blk, cerr := aes.NewCipher(keyBytes) + if cerr == nil { + iv := make([]byte, aes.BlockSize) + stream := cipher.NewCTR(blk, iv) + decryptedTest := make([]byte, len(encryptedData)) + stream.XORKeyStream(decryptedTest, encryptedData) + sumDec := sha256.Sum256(decryptedTest) + matches := bytes.Equal(decryptedTest, imageData) + logger.Debug().Str("decrypted_sha256", hex.EncodeToString(sumDec[:])).Bool("decryption_matches_original", matches).Msg("local decryption check") + } else { + logger.Warn().Err(cerr).Msg("local decryption check: failed to create cipher with key") + } + } else { + logger.Warn().Err(derr).Msg("local decryption check: failed to decode hex key") + } + } + + mmmsgURL, err := s.uploadToMMMSG(ctx, encryptedData, contentType) + if err != nil { + logger.Error().Err(err).Msg("failed to upload image to MMMSG, skipping attachment") + } else { + attachment := models.Attachment{ + ContentType: contentType, + ContentURL: mmmsgURL, + ContentSize: len(encryptedData), + Filename: "", + EncryptionKey: encryptionKey, + } + + if preview != "" { + attachment.Preview = &models.AttachmentPreview{ + ContentType: "image/jpeg", + Content: preview, + } + } + + // Create the FileTransfer object and marshal it to JSON for SMSText + ft := models.FileTransfer{ + Body: body, + Attachments: []models.Attachment{attachment}, + } + + ftJSON, err := json.Marshal(ft) + if err != nil { + logger.Error().Err(err).Msg("failed to marshal file transfer JSON") + } else { + sms.SMSText = string(ftJSON) + sms.ContentType = "application/x-acro-filetransfer+json" + } + + logger.Debug(). + Str("event_id", string(evt.ID)). + Str("mxc_url", mxcURL). + Str("mmmsg_url", mmmsgURL). + Int("size", len(encryptedData)). + Msg("processed image attachment via MMMSG") + } + } + } + } + // Determine if I sent the message senderMatrixID := string(evt.Sender) isSent := isSentBy(senderMatrixID, string(userID)) @@ -782,3 +931,94 @@ func (s *MessageService) clearBatchToken(userID string) { defer s.mu.Unlock() delete(s.batchTokens, userID) } + +// uploadToMMMSG uploads data to Acrobits MMMSG server and returns the download URL. +func (s *MessageService) uploadToMMMSG(ctx context.Context, data []byte, contentType string) (string, error) { + // 1. POST to MMMSGURL to get upload URL + reqBody := map[string]interface{}{ + "content-type": contentType, + "content-length": len(data), + } + reqBytes, _ := json.Marshal(reqBody) + + logger.Debug().Str("mmmsg_url", s.mmmsgURL).Str("content_type", contentType).Int("size", len(data)).Msg("requesting upload URL from MMMSG") + + req, err := http.NewRequestWithContext(ctx, "POST", s.mmmsgURL, bytes.NewReader(reqBytes)) + if err != nil { + return "", fmt.Errorf("failed to create MMMSG POST request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to request MMMSG upload URL: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("MMMSG POST failed with status %d: %s", resp.StatusCode, string(body)) + } + + var respData struct { + URL string `json:"url"` + } + if err := json.NewDecoder(resp.Body).Decode(&respData); err != nil { + return "", fmt.Errorf("failed to decode MMMSG response: %w", err) + } + + if respData.URL == "" { + return "", fmt.Errorf("MMMSG response did not contain a URL") + } + + logger.Debug().Str("upload_url", respData.URL).Msg("received upload URL from MMMSG") + + // 2. PUT data to upload URL + // Log checksum of data we're about to PUT for debugging + sum := sha256.Sum256(data) + logger.Debug().Str("upload_sha256", hex.EncodeToString(sum[:])).Str("upload_url", respData.URL).Msg("uploading data to MMMSG") + + putReq, err := http.NewRequestWithContext(ctx, "PUT", respData.URL, bytes.NewReader(data)) + if err != nil { + return "", fmt.Errorf("failed to create MMMSG PUT request: %w", err) + } + putReq.Header.Set("Content-Type", contentType) + + putResp, err := http.DefaultClient.Do(putReq) + if err != nil { + return "", fmt.Errorf("failed to upload data to MMMSG: %w", err) + } + defer putResp.Body.Close() + + if putResp.StatusCode != http.StatusOK && putResp.StatusCode != http.StatusCreated && putResp.StatusCode != http.StatusNoContent { + body, _ := io.ReadAll(putResp.Body) + return "", fmt.Errorf("MMMSG PUT failed with status %d: %s", putResp.StatusCode, string(body)) + } + + logger.Info().Str("download_url", respData.URL).Msg("successfully uploaded file to MMMSG") + + return respData.URL, nil +} + +// encryptData encrypts data using AES-CTR with a random 128-bit key and zero IV. +// Returns the encrypted data and the hex-encoded key. +func encryptData(data []byte) ([]byte, string, error) { + key := make([]byte, 16) // 128-bit key + if _, err := rand.Read(key); err != nil { + return nil, "", err + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, "", err + } + + // Acrobits uses AES-CTR with zero IV for MMMSG + iv := make([]byte, aes.BlockSize) + stream := cipher.NewCTR(block, iv) + + encrypted := make([]byte, len(data)) + stream.XORKeyStream(encrypted, data) + + return encrypted, hex.EncodeToString(key), nil +} diff --git a/service/messages_test.go b/service/messages_test.go index 1b91ad5..c61d21c 100644 --- a/service/messages_test.go +++ b/service/messages_test.go @@ -472,20 +472,6 @@ func TestReportPushToken(t *testing.T) { }) } -// fakeHTTPAuthClient allows controlling responses for testing. -type fakeHTTPAuthClient struct { - ok bool -} - -func (f *fakeHTTPAuthClient) Validate(ctx context.Context, username, password, homeserverHost string) ([]*models.MappingRequest, bool, error) { - if f.ok { - return []*models.MappingRequest{ - {Number: 1, MatrixID: "@alice:" + homeserverHost, SubNumbers: []int{}}, - }, true, nil - } - return []*models.MappingRequest{}, false, fmt.Errorf("unauthorized") -} - func TestSaveMapping_Success(t *testing.T) { dbi, err := db.NewDatabase(":memory:") require.NoError(t, err)