diff --git a/web/constants.go b/web/constants.go new file mode 100644 index 0000000..e51c7dc --- /dev/null +++ b/web/constants.go @@ -0,0 +1,8 @@ +package web + +const ( + ChatIOSRedirectURL = "https://apps.apple.com/fr/app/twake-chat/id6473384641" + ChatAndroidRedirectURL = "https://play.google.com/store/apps/details?id=app.twake.android.chat" + ChatFallbackURL = "https://twake.app" + ChatInvitePrefix = "/chat/@" +) diff --git a/web/matrix.go b/web/matrix.go new file mode 100644 index 0000000..9e20b69 --- /dev/null +++ b/web/matrix.go @@ -0,0 +1,39 @@ +package web + +import ( + "strings" +) + +func ExtractMatrixID(path string) string { + if !strings.HasPrefix(path, ChatInvitePrefix) { + return "" + } + return strings.TrimPrefix(path, "/chat/") +} + +func ExtractDomainFromMatrixID(matrixID string) string { + if matrixID == "" || !strings.Contains(matrixID, ":") { + return "" + } + parts := strings.Split(matrixID, ":") + if len(parts) != 2 { + return "" + } + domain := parts[1] + return domain +} + +func GetSignUpURLForDomain(domain string) string { + switch domain { + case "twake.app": + return "sign-up.twake.app" + case "stg.lin-saas.com": + return "sign-up.stg.lin-saas.com" + case "cozy.lin-saas.com": + return "sign-up.cozy.lin-saas.com" + case "qa.lin-saas.com": + return "sign-up.qa.lin-saas.com" + default: + return "" + } +} diff --git a/web/matrix_test.go b/web/matrix_test.go new file mode 100644 index 0000000..a88e127 --- /dev/null +++ b/web/matrix_test.go @@ -0,0 +1,85 @@ +package web + +import ( + "testing" +) + +func TestExtractMatrixID(t *testing.T) { + tests := []struct { + name string + path string + want string + wantErr bool + }{ + { + name: "Valid chat invite path", + path: "/chat/@jdoe:twake.app", + want: "@jdoe:twake.app", + wantErr: false, + }, + { + name: "Invalid path - missing prefix", + path: "/@jdoe:twake.app", + want: "", + wantErr: true, + }, + { + name: "Empty path", + path: "", + want: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ExtractMatrixID(tt.path) + if tt.wantErr && got != "" { + t.Errorf("ExtractMatrixID() expected error but got result: %v", got) + } + if !tt.wantErr && got != tt.want { + t.Errorf("ExtractMatrixID() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestExtractDomainFromMatrixID(t *testing.T) { + tests := []struct { + name string + matrixID string + want string + wantErr bool + }{ + { + name: "Valid Matrix ID", + matrixID: "@jdoe:twake.app", + want: "twake.app", + wantErr: false, + }, + { + name: "Invalid Matrix ID - no colon", + matrixID: "@jdoe", + want: "", + wantErr: true, + }, + { + name: "Empty Matrix ID", + matrixID: "", + want: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ExtractDomainFromMatrixID(tt.matrixID) + if tt.wantErr && got != "" { + t.Errorf("ExtractDomainFromMatrixID() expected error but got result: %v", got) + } + if !tt.wantErr && got != tt.want { + t.Errorf("ExtractDomainFromMatrixID() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/web/universal_links.go b/web/universal_links.go index 0d29c98..59d0f11 100644 --- a/web/universal_links.go +++ b/web/universal_links.go @@ -30,6 +30,66 @@ func universalLink(c echo.Context) error { return c.String(http.StatusOK, content.String()) } +func isChatInvite(c echo.Context) bool { + requestPath := c.Request().URL.Path + return strings.HasPrefix(requestPath, ChatInvitePrefix) +} + +func createDirectChatURL(baseURL, requestPath string) string { + parsedURL := url.URL{ + Scheme: "https", + Host: baseURL, + Path: "/", + } + + query := parsedURL.Query() + query.Set("redirect", "true") + query.Set("slug", "chat") + query.Set("path", "/#/bridge/web/#"+requestPath) + + parsedURL.RawQuery = query.Encode() + return parsedURL.String() +} + +func redirectChatInvite(c echo.Context) (bool, error) { + userAgent := c.Request().UserAgent() + platform := GetPlatformFromUserAgent(userAgent) + + var redirectURL string + switch platform { + case "ios": + redirectURL = ChatIOSRedirectURL + case "android": + redirectURL = ChatAndroidRedirectURL + case "web": + // Extract Matrix ID and domain to determine the sign-up URL + requestPath := c.Request().URL.Path + matrixID := ExtractMatrixID(requestPath) + if matrixID == "" { + redirectURL = ChatFallbackURL + break + } + + domain := ExtractDomainFromMatrixID(matrixID) + if domain == "" { + redirectURL = ChatFallbackURL + break + } + + signUpURL := GetSignUpURLForDomain(domain) + if signUpURL == "" { + redirectURL = ChatFallbackURL + break + } + + redirectURL = createDirectChatURL(signUpURL, requestPath) + default: + redirectURL = ChatFallbackURL + } + + return true, c.Redirect(http.StatusSeeOther, redirectURL) +} + func universalLinkRedirect(c echo.Context) error { space, err := getSpaceFromHost(c) if err != nil { @@ -38,6 +98,12 @@ func universalLinkRedirect(c echo.Context) error { spacePrefix := space.GetPrefix() fallback := c.QueryParam("fallback") + // Custom redirection for chat app invite links + if isChatInvite(c) { + _, err := redirectChatInvite(c) + return err + } + // The following code has been made to handle an iOS bug during JSON recovery. // It should be removed if a fix is found one day. // See https://openradar.appspot.com/33893852 diff --git a/web/universal_links_test.go b/web/universal_links_test.go new file mode 100644 index 0000000..9069909 --- /dev/null +++ b/web/universal_links_test.go @@ -0,0 +1,36 @@ +package web + +import ( + "testing" +) + +func TestCreateDirectChatURL(t *testing.T) { + tests := []struct { + name string + baseURL string + path string + want string + }{ + { + name: "Basic chat invite URL", + baseURL: "sign-up.twake.app", + path: "/chat/@jdoe:twake.app", + want: "https://sign-up.twake.app/?path=%2F%23%2Fbridge%2Fweb%2F%23%2Fchat%2F%40jdoe%3Atwake.app&redirect=true&slug=chat", + }, + { + name: "Staging chat invite URL", + baseURL: "sign-up.stg.lin-saas.com", + path: "/chat/@jdoe:stg.lin-saas.com", + want: "https://sign-up.stg.lin-saas.com/?path=%2F%23%2Fbridge%2Fweb%2F%23%2Fchat%2F%40jdoe%3Astg.lin-saas.com&redirect=true&slug=chat", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := createDirectChatURL(tt.baseURL, tt.path) + if got != tt.want { + t.Errorf("createDirectChatURL() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/web/user_agent.go b/web/user_agent.go new file mode 100644 index 0000000..542cda3 --- /dev/null +++ b/web/user_agent.go @@ -0,0 +1,19 @@ +package web + +import "strings" + +func GetPlatformFromUserAgent(userAgent string) string { + userAgentLower := strings.ToLower(userAgent) + + if strings.Contains(userAgentLower, "iphone") || + strings.Contains(userAgentLower, "ipad") || + strings.Contains(userAgentLower, "ipod") { + return "ios" + } + + if strings.Contains(userAgentLower, "android") { + return "android" + } + + return "web" +} diff --git a/web/user_agent_test.go b/web/user_agent_test.go new file mode 100644 index 0000000..35a1fb6 --- /dev/null +++ b/web/user_agent_test.go @@ -0,0 +1,85 @@ +package web + +import "testing" + +func TestGetPlatformFromUserAgent(t *testing.T) { + tests := []struct { + name string + userAgent string + want string + }{ + // iOS + { + name: "iPhone", + userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Mobile/15E148 Safari/604.1", + want: "ios", + }, + { + name: "iPad", + userAgent: "Mozilla/5.0 (iPad; CPU OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1", + want: "ios", + }, + { + name: "iPod", + userAgent: "Mozilla/5.0 (iPod touch; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1", + want: "ios", + }, + // Android + { + name: "Android phone", + userAgent: "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.91 Mobile Safari/537.36", + want: "android", + }, + { + name: "Android tablet", + userAgent: "Mozilla/5.0 (Linux; Android 10; SM-T510) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.105 Safari/537.36", + want: "android", + }, + // Web + { + name: "Chrome on Windows", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + want: "web", + }, + { + name: "Firefox on macOS", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:89.0) Gecko/20100101 Firefox/89.0", + want: "web", + }, + { + name: "Safari on macOS", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Safari/605.1.15", + want: "web", + }, + { + name: "Edge on Windows", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.59", + want: "web", + }, + { + name: "Chrome on Linux", + userAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36", + want: "web", + }, + // Edge cases + { + name: "Empty user agent", + userAgent: "", + want: "web", + }, + { + name: "Unknown user agent", + userAgent: "SomeBot/1.0", + want: "web", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetPlatformFromUserAgent(tt.userAgent) + if got != tt.want { + t.Errorf("GetPlatformFromUserAgent() = %v, want %v", got, tt.want) + } + }) + } +}