Refactor hostname to become baseurl
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
@@ -74,13 +74,13 @@ func (r AccessResult) ToError(baseURL string) error {
|
||||
case AuthMethodPassword:
|
||||
return ErrPasswordAuthRequired{
|
||||
OrganizationID: r.OrganizationID,
|
||||
RedirectURL: fmt.Sprintf("%s/authentication/login?method=password", baseURL),
|
||||
RedirectURL: fmt.Sprintf("%s/auth/login?method=password", baseURL),
|
||||
}
|
||||
case AuthMethodSAML, AuthMethodAny:
|
||||
return ErrSAMLAuthRequired{
|
||||
ConfigID: r.SAMLConfig.ID,
|
||||
OrganizationID: r.OrganizationID,
|
||||
RedirectURL: fmt.Sprintf("%s/auth/saml/login/%s", baseURL, r.SAMLConfig.ID),
|
||||
RedirectURL: fmt.Sprintf("%s/connect/saml/login/%s", baseURL, r.SAMLConfig.ID),
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("access denied to organization %s", r.OrganizationID)
|
||||
|
||||
@@ -224,11 +224,11 @@ func NewSAMLService(
|
||||
}
|
||||
|
||||
func (s *SAMLService) GetEntityID() string {
|
||||
return fmt.Sprintf("%s/auth/saml/metadata", s.baseURL)
|
||||
return fmt.Sprintf("%s/connect/saml/metadata", s.baseURL)
|
||||
}
|
||||
|
||||
func (s *SAMLService) GetAcsURL() string {
|
||||
return fmt.Sprintf("%s/auth/saml/consume", s.baseURL)
|
||||
return fmt.Sprintf("%s/connect/saml/consume", s.baseURL)
|
||||
}
|
||||
|
||||
func parseRawSAMLResponse(encodedResponse string) (*saml.Assertion, error) {
|
||||
@@ -564,7 +564,7 @@ func (s *SAMLService) HandleSAMLAssertion(
|
||||
}
|
||||
|
||||
func (s *SAMLService) GetMetadataURL(organizationID gid.GID) string {
|
||||
return fmt.Sprintf("%s/auth/saml/metadata/%s", s.baseURL, organizationID)
|
||||
return fmt.Sprintf("%s/connect/saml/metadata/%s", s.baseURL, organizationID)
|
||||
}
|
||||
|
||||
func (s *SAMLService) GenerateMetadata() ([]byte, error) {
|
||||
|
||||
@@ -22,10 +22,10 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/packages/emails"
|
||||
"github.com/getprobo/probo/pkg/baseurl"
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/crypto/cipher"
|
||||
"github.com/getprobo/probo/pkg/crypto/passwdhash"
|
||||
@@ -40,7 +40,6 @@ type (
|
||||
pg *pg.Client
|
||||
encryptionKey cipher.EncryptionKey
|
||||
hp *passwdhash.Profile
|
||||
hostname string
|
||||
baseURL string
|
||||
tokenSecret string
|
||||
disableSignup bool
|
||||
@@ -51,7 +50,6 @@ type (
|
||||
pg *pg.Client
|
||||
encryptionKey cipher.EncryptionKey
|
||||
hp *passwdhash.Profile
|
||||
hostname string
|
||||
baseURL string
|
||||
tokenSecret string
|
||||
scope coredata.Scoper
|
||||
@@ -187,7 +185,6 @@ func NewService(
|
||||
encryptionKey cipher.EncryptionKey,
|
||||
hp *passwdhash.Profile,
|
||||
tokenSecret string,
|
||||
hostname string,
|
||||
baseURL string,
|
||||
disableSignup bool,
|
||||
invitationTokenValidity time.Duration,
|
||||
@@ -196,7 +193,6 @@ func NewService(
|
||||
pg: pgClient,
|
||||
encryptionKey: encryptionKey,
|
||||
hp: hp,
|
||||
hostname: hostname,
|
||||
baseURL: baseURL,
|
||||
tokenSecret: tokenSecret,
|
||||
disableSignup: disableSignup,
|
||||
@@ -209,7 +205,6 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantAuthService {
|
||||
pg: s.pg,
|
||||
encryptionKey: s.encryptionKey,
|
||||
hp: s.hp,
|
||||
hostname: s.hostname,
|
||||
baseURL: s.baseURL,
|
||||
tokenSecret: s.tokenSecret,
|
||||
scope: coredata.NewScope(tenantID),
|
||||
@@ -232,13 +227,17 @@ func (s Service) ForgetPassword(
|
||||
return fmt.Errorf("cannot generate password reset token: %w", err)
|
||||
}
|
||||
|
||||
resetPasswordUrl := url.URL{
|
||||
Scheme: "https",
|
||||
Host: s.hostname,
|
||||
Path: "/auth/reset-password",
|
||||
RawQuery: url.Values{
|
||||
"token": []string{passwordResetToken},
|
||||
}.Encode(),
|
||||
base, err := baseurl.Parse(s.baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse base URL: %w", err)
|
||||
}
|
||||
|
||||
resetPasswordUrl, err := base.
|
||||
WithPath("/auth/reset-password").
|
||||
WithQuery("token", passwordResetToken).
|
||||
String()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot build reset password URL: %w", err)
|
||||
}
|
||||
|
||||
return s.pg.WithConn(
|
||||
@@ -255,9 +254,9 @@ func (s Service) ForgetPassword(
|
||||
}
|
||||
|
||||
subject, textBody, htmlBody, err := emails.RenderPasswordReset(
|
||||
s.hostname,
|
||||
s.baseURL,
|
||||
user.FullName,
|
||||
resetPasswordUrl.String(),
|
||||
resetPasswordUrl,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot render password reset email: %w", err)
|
||||
@@ -352,19 +351,23 @@ func (s Service) SignUp(
|
||||
return fmt.Errorf("cannot generate confirmation token: %w", err)
|
||||
}
|
||||
|
||||
confirmationUrl := url.URL{
|
||||
Scheme: "https",
|
||||
Host: s.hostname,
|
||||
Path: "/auth/confirm-email",
|
||||
RawQuery: url.Values{
|
||||
"token": []string{confirmationToken},
|
||||
}.Encode(),
|
||||
base, err := baseurl.Parse(s.baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse base URL: %w", err)
|
||||
}
|
||||
|
||||
confirmationUrl, err := base.
|
||||
WithPath("/auth/confirm-email").
|
||||
WithQuery("token", confirmationToken).
|
||||
String()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot build confirmation URL: %w", err)
|
||||
}
|
||||
|
||||
subject, textBody, htmlBody, err := emails.RenderConfirmEmail(
|
||||
s.hostname,
|
||||
s.baseURL,
|
||||
user.FullName,
|
||||
confirmationUrl.String(),
|
||||
confirmationUrl,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot render confirmation email: %w", err)
|
||||
|
||||
@@ -32,14 +32,14 @@ import (
|
||||
type (
|
||||
Service struct {
|
||||
pg *pg.Client
|
||||
hostname string
|
||||
baseURL string
|
||||
tokenSecret string
|
||||
invitationTokenValidity time.Duration
|
||||
}
|
||||
|
||||
TenantAuthzService struct {
|
||||
pg *pg.Client
|
||||
hostname string
|
||||
baseURL string
|
||||
tokenSecret string
|
||||
invitationTokenValidity time.Duration
|
||||
scope coredata.Scoper
|
||||
@@ -62,13 +62,13 @@ const (
|
||||
func NewService(
|
||||
ctx context.Context,
|
||||
pgClient *pg.Client,
|
||||
hostname string,
|
||||
baseURL string,
|
||||
tokenSecret string,
|
||||
invitationTokenValidity time.Duration,
|
||||
) (*Service, error) {
|
||||
return &Service{
|
||||
pg: pgClient,
|
||||
hostname: hostname,
|
||||
baseURL: baseURL,
|
||||
tokenSecret: tokenSecret,
|
||||
invitationTokenValidity: invitationTokenValidity,
|
||||
}, nil
|
||||
@@ -77,7 +77,7 @@ func NewService(
|
||||
func (s *Service) WithTenant(tenantID gid.TenantID) *TenantAuthzService {
|
||||
return &TenantAuthzService{
|
||||
pg: s.pg,
|
||||
hostname: s.hostname,
|
||||
baseURL: s.baseURL,
|
||||
tokenSecret: s.tokenSecret,
|
||||
invitationTokenValidity: s.invitationTokenValidity,
|
||||
scope: coredata.NewScope(tenantID),
|
||||
@@ -743,7 +743,7 @@ func (s *TenantAuthzService) InviteUserToOrganization(
|
||||
|
||||
if userExists {
|
||||
recipientName = user.FullName
|
||||
invitationURL = fmt.Sprintf("https://%s/", s.hostname)
|
||||
invitationURL = s.baseURL + "/"
|
||||
} else {
|
||||
recipientName = fullName
|
||||
invitationData := coredata.InvitationData{
|
||||
@@ -764,11 +764,11 @@ func (s *TenantAuthzService) InviteUserToOrganization(
|
||||
return fmt.Errorf("cannot generate invitation token: %w", err)
|
||||
}
|
||||
|
||||
invitationURL = fmt.Sprintf("https://%s/auth/signup-from-invitation?token=%s&fullName=%s", s.hostname, invitationToken, url.QueryEscape(fullName))
|
||||
invitationURL = fmt.Sprintf("%s/auth/signup-from-invitation?token=%s&fullName=%s", s.baseURL, invitationToken, url.QueryEscape(fullName))
|
||||
}
|
||||
|
||||
subject, textBody, htmlBody, err := emails.RenderInvitation(
|
||||
s.hostname,
|
||||
s.baseURL,
|
||||
recipientName,
|
||||
organization.Name,
|
||||
invitationURL,
|
||||
|
||||
226
pkg/baseurl/baseurl.go
Normal file
226
pkg/baseurl/baseurl.go
Normal file
@@ -0,0 +1,226 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package baseurl
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BaseURL represents a validated base URL for the application.
|
||||
// It provides convenient methods for building URLs with paths and query parameters.
|
||||
type BaseURL struct {
|
||||
raw string
|
||||
parsed *url.URL
|
||||
}
|
||||
|
||||
// Parse creates a new BaseURL from a string, validating that it's a valid absolute URL.
|
||||
func Parse(rawURL string) (*BaseURL, error) {
|
||||
if rawURL == "" {
|
||||
return nil, fmt.Errorf("base URL cannot be empty")
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid base URL: %w", err)
|
||||
}
|
||||
|
||||
if !parsed.IsAbs() {
|
||||
return nil, fmt.Errorf("base URL must be absolute (include scheme)")
|
||||
}
|
||||
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return nil, fmt.Errorf("base URL scheme must be http or https, got: %s", parsed.Scheme)
|
||||
}
|
||||
|
||||
if parsed.Host == "" {
|
||||
return nil, fmt.Errorf("base URL must include a host")
|
||||
}
|
||||
|
||||
return &BaseURL{
|
||||
raw: rawURL,
|
||||
parsed: parsed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MustParse creates a new BaseURL from a string, panicking if it's invalid.
|
||||
// This should only be used in tests or with known-valid URLs.
|
||||
func MustParse(rawURL string) *BaseURL {
|
||||
b, err := Parse(rawURL)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// String returns the base URL as a string.
|
||||
func (b *BaseURL) String() string {
|
||||
if b == nil {
|
||||
return ""
|
||||
}
|
||||
return b.raw
|
||||
}
|
||||
|
||||
// Scheme returns the URL scheme (http or https).
|
||||
func (b *BaseURL) Scheme() string {
|
||||
if b == nil || b.parsed == nil {
|
||||
return ""
|
||||
}
|
||||
return b.parsed.Scheme
|
||||
}
|
||||
|
||||
// Host returns the host:port portion of the URL.
|
||||
func (b *BaseURL) Host() string {
|
||||
if b == nil || b.parsed == nil {
|
||||
return ""
|
||||
}
|
||||
return b.parsed.Host
|
||||
}
|
||||
|
||||
// Hostname returns just the hostname without the port.
|
||||
func (b *BaseURL) Hostname() string {
|
||||
if b == nil || b.parsed == nil {
|
||||
return ""
|
||||
}
|
||||
return b.parsed.Hostname()
|
||||
}
|
||||
|
||||
// Port returns the port portion of the URL, or empty string if not specified.
|
||||
func (b *BaseURL) Port() string {
|
||||
if b == nil || b.parsed == nil {
|
||||
return ""
|
||||
}
|
||||
return b.parsed.Port()
|
||||
}
|
||||
|
||||
// URLBuilder provides a fluent interface for building URLs.
|
||||
type URLBuilder struct {
|
||||
base *BaseURL
|
||||
path string
|
||||
query url.Values
|
||||
err error
|
||||
}
|
||||
|
||||
// WithPath returns a URLBuilder with the specified path.
|
||||
// The path will be properly joined with the base URL.
|
||||
func (b *BaseURL) WithPath(path string) *URLBuilder {
|
||||
if b == nil {
|
||||
return &URLBuilder{err: fmt.Errorf("base URL is nil")}
|
||||
}
|
||||
|
||||
// Ensure path starts with /
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
|
||||
return &URLBuilder{
|
||||
base: b,
|
||||
path: path,
|
||||
query: make(url.Values),
|
||||
}
|
||||
}
|
||||
|
||||
// WithQuery adds a query parameter to the URL.
|
||||
func (ub *URLBuilder) WithQuery(key, value string) *URLBuilder {
|
||||
if ub.err != nil {
|
||||
return ub
|
||||
}
|
||||
ub.query.Add(key, value)
|
||||
return ub
|
||||
}
|
||||
|
||||
// WithQueryValues sets multiple query parameters at once.
|
||||
func (ub *URLBuilder) WithQueryValues(values url.Values) *URLBuilder {
|
||||
if ub.err != nil {
|
||||
return ub
|
||||
}
|
||||
for key, vals := range values {
|
||||
for _, val := range vals {
|
||||
ub.query.Add(key, val)
|
||||
}
|
||||
}
|
||||
return ub
|
||||
}
|
||||
|
||||
// String builds and returns the final URL string.
|
||||
func (ub *URLBuilder) String() (string, error) {
|
||||
if ub.err != nil {
|
||||
return "", ub.err
|
||||
}
|
||||
|
||||
u := &url.URL{
|
||||
Scheme: ub.base.Scheme(),
|
||||
Host: ub.base.Host(),
|
||||
Path: ub.path,
|
||||
RawQuery: ub.query.Encode(),
|
||||
}
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// MustString builds and returns the final URL string, panicking on error.
|
||||
// This should only be used when you're certain the URL is valid.
|
||||
func (ub *URLBuilder) MustString() string {
|
||||
s, err := ub.String()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler for BaseURL.
|
||||
func (b *BaseURL) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
parsed, err := Parse(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*b = *parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler for BaseURL.
|
||||
func (b *BaseURL) MarshalJSON() ([]byte, error) {
|
||||
if b == nil {
|
||||
return json.Marshal("")
|
||||
}
|
||||
return json.Marshal(b.raw)
|
||||
}
|
||||
|
||||
// UnmarshalText implements encoding.TextUnmarshaler for BaseURL.
|
||||
func (b *BaseURL) UnmarshalText(text []byte) error {
|
||||
parsed, err := Parse(string(text))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*b = *parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalText implements encoding.TextMarshaler for BaseURL.
|
||||
func (b *BaseURL) MarshalText() ([]byte, error) {
|
||||
if b == nil {
|
||||
return []byte(""), nil
|
||||
}
|
||||
return []byte(b.raw), nil
|
||||
}
|
||||
243
pkg/baseurl/baseurl_test.go
Normal file
243
pkg/baseurl/baseurl_test.go
Normal file
@@ -0,0 +1,243 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package baseurl
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid http URL",
|
||||
input: "http://localhost:8080",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid https URL",
|
||||
input: "https://example.com",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid https URL with port",
|
||||
input: "https://example.com:8443",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "relative URL",
|
||||
input: "/path/to/resource",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid scheme",
|
||||
input: "ftp://example.com",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "no host",
|
||||
input: "http://",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid URL",
|
||||
input: "ht!tp://invalid",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := Parse(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !tt.wantErr && got == nil {
|
||||
t.Error("Parse() returned nil without error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURL_Accessors(t *testing.T) {
|
||||
b := MustParse("https://example.com:8443")
|
||||
|
||||
if got := b.String(); got != "https://example.com:8443" {
|
||||
t.Errorf("String() = %v, want %v", got, "https://example.com:8443")
|
||||
}
|
||||
|
||||
if got := b.Scheme(); got != "https" {
|
||||
t.Errorf("Scheme() = %v, want %v", got, "https")
|
||||
}
|
||||
|
||||
if got := b.Host(); got != "example.com:8443" {
|
||||
t.Errorf("Host() = %v, want %v", got, "example.com:8443")
|
||||
}
|
||||
|
||||
if got := b.Hostname(); got != "example.com" {
|
||||
t.Errorf("Hostname() = %v, want %v", got, "example.com")
|
||||
}
|
||||
|
||||
if got := b.Port(); got != "8443" {
|
||||
t.Errorf("Port() = %v, want %v", got, "8443")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURL_WithPath(t *testing.T) {
|
||||
b := MustParse("https://example.com")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "path with leading slash",
|
||||
path: "/auth/login",
|
||||
want: "https://example.com/auth/login",
|
||||
},
|
||||
{
|
||||
name: "path without leading slash",
|
||||
path: "auth/login",
|
||||
want: "https://example.com/auth/login",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := b.WithPath(tt.path).String()
|
||||
if err != nil {
|
||||
t.Errorf("WithPath().String() error = %v", err)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("WithPath().String() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURL_WithQuery(t *testing.T) {
|
||||
b := MustParse("https://example.com")
|
||||
|
||||
got, err := b.WithPath("/search").
|
||||
WithQuery("q", "test").
|
||||
WithQuery("limit", "10").
|
||||
String()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("WithPath().WithQuery().String() error = %v", err)
|
||||
}
|
||||
|
||||
// Parse the result to check query parameters
|
||||
parsed, err := url.Parse(got)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse result URL: %v", err)
|
||||
}
|
||||
|
||||
if parsed.Query().Get("q") != "test" {
|
||||
t.Errorf("Query param 'q' = %v, want %v", parsed.Query().Get("q"), "test")
|
||||
}
|
||||
|
||||
if parsed.Query().Get("limit") != "10" {
|
||||
t.Errorf("Query param 'limit' = %v, want %v", parsed.Query().Get("limit"), "10")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURL_WithQueryValues(t *testing.T) {
|
||||
b := MustParse("https://example.com")
|
||||
|
||||
values := url.Values{}
|
||||
values.Add("foo", "bar")
|
||||
values.Add("baz", "qux")
|
||||
|
||||
got, err := b.WithPath("/test").WithQueryValues(values).String()
|
||||
if err != nil {
|
||||
t.Fatalf("WithPath().WithQueryValues().String() error = %v", err)
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(got)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse result URL: %v", err)
|
||||
}
|
||||
|
||||
if parsed.Query().Get("foo") != "bar" {
|
||||
t.Errorf("Query param 'foo' = %v, want %v", parsed.Query().Get("foo"), "bar")
|
||||
}
|
||||
|
||||
if parsed.Query().Get("baz") != "qux" {
|
||||
t.Errorf("Query param 'baz' = %v, want %v", parsed.Query().Get("baz"), "qux")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURL_JSON(t *testing.T) {
|
||||
original := MustParse("https://example.com:8443")
|
||||
|
||||
// Marshal
|
||||
data, err := json.Marshal(original)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error = %v", err)
|
||||
}
|
||||
|
||||
// Unmarshal
|
||||
var restored BaseURL
|
||||
if err := json.Unmarshal(data, &restored); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
|
||||
if restored.String() != original.String() {
|
||||
t.Errorf("After JSON round-trip: got %v, want %v", restored.String(), original.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURL_NilSafety(t *testing.T) {
|
||||
var b *BaseURL
|
||||
|
||||
if got := b.String(); got != "" {
|
||||
t.Errorf("nil.String() = %v, want empty string", got)
|
||||
}
|
||||
|
||||
if got := b.Scheme(); got != "" {
|
||||
t.Errorf("nil.Scheme() = %v, want empty string", got)
|
||||
}
|
||||
|
||||
if got := b.Host(); got != "" {
|
||||
t.Errorf("nil.Host() = %v, want empty string", got)
|
||||
}
|
||||
|
||||
if got := b.Hostname(); got != "" {
|
||||
t.Errorf("nil.Hostname() = %v, want empty string", got)
|
||||
}
|
||||
|
||||
if got := b.Port(); got != "" {
|
||||
t.Errorf("nil.Port() = %v, want empty string", got)
|
||||
}
|
||||
|
||||
builder := b.WithPath("/test")
|
||||
if _, err := builder.String(); err == nil {
|
||||
t.Error("nil.WithPath().String() expected error, got nil")
|
||||
}
|
||||
}
|
||||
@@ -457,9 +457,14 @@ func (s *DocumentService) SendSigningNotifications(
|
||||
return fmt.Errorf("cannot create signing request token: %w", err)
|
||||
}
|
||||
|
||||
baseURLParsed, err := url.Parse(s.svc.baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse base URL: %w", err)
|
||||
}
|
||||
|
||||
signRequestURL := url.URL{
|
||||
Scheme: "https",
|
||||
Host: s.svc.hostname,
|
||||
Scheme: baseURLParsed.Scheme,
|
||||
Host: baseURLParsed.Host,
|
||||
Path: "/documents/signing-requests",
|
||||
RawQuery: url.Values{
|
||||
"token": []string{token},
|
||||
@@ -467,7 +472,7 @@ func (s *DocumentService) SendSigningNotifications(
|
||||
}
|
||||
|
||||
subject, textBody, htmlBody, err := emails.RenderDocumentSigning(
|
||||
s.svc.hostname,
|
||||
s.svc.baseURL,
|
||||
people.FullName,
|
||||
organization.Name,
|
||||
signRequestURL.String(),
|
||||
@@ -1584,7 +1589,7 @@ func (s *DocumentService) SendExportEmail(
|
||||
}
|
||||
|
||||
subject, textBody, htmlBody, err := emails.RenderDocumentExport(
|
||||
s.svc.hostname,
|
||||
s.svc.baseURL,
|
||||
recipientName,
|
||||
downloadURL,
|
||||
)
|
||||
|
||||
@@ -709,7 +709,7 @@ func (s FrameworkService) SendExportEmail(
|
||||
}
|
||||
|
||||
subject, textBody, htmlBody, err := emails.RenderFrameworkExport(
|
||||
s.svc.hostname,
|
||||
s.svc.baseURL,
|
||||
recipientName,
|
||||
downloadURL,
|
||||
)
|
||||
|
||||
@@ -52,7 +52,7 @@ type (
|
||||
s3 *s3.Client
|
||||
bucket string
|
||||
encryptionKey cipher.EncryptionKey
|
||||
hostname string
|
||||
baseURL string
|
||||
tokenSecret string
|
||||
trustConfig TrustConfig
|
||||
agentConfig agents.Config
|
||||
@@ -70,7 +70,7 @@ type (
|
||||
bucket string
|
||||
encryptionKey cipher.EncryptionKey
|
||||
scope coredata.Scoper
|
||||
hostname string
|
||||
baseURL string
|
||||
tokenSecret string
|
||||
trustConfig TrustConfig
|
||||
agent *agents.Agent
|
||||
@@ -115,7 +115,7 @@ func NewService(
|
||||
pgClient *pg.Client,
|
||||
s3Client *s3.Client,
|
||||
bucket string,
|
||||
hostname string,
|
||||
baseURL string,
|
||||
tokenSecret string,
|
||||
trustConfig TrustConfig,
|
||||
agentConfig agents.Config,
|
||||
@@ -135,7 +135,7 @@ func NewService(
|
||||
s3: s3Client,
|
||||
bucket: bucket,
|
||||
encryptionKey: encryptionKey,
|
||||
hostname: hostname,
|
||||
baseURL: baseURL,
|
||||
tokenSecret: tokenSecret,
|
||||
trustConfig: trustConfig,
|
||||
agentConfig: agentConfig,
|
||||
@@ -156,7 +156,7 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
|
||||
s3: s.s3,
|
||||
bucket: s.bucket,
|
||||
encryptionKey: s.encryptionKey,
|
||||
hostname: s.hostname,
|
||||
baseURL: s.baseURL,
|
||||
scope: coredata.NewScope(tenantID),
|
||||
tokenSecret: s.tokenSecret,
|
||||
trustConfig: s.trustConfig,
|
||||
|
||||
@@ -442,7 +442,13 @@ func (s TrustCenterAccessService) sendAccessEmail(ctx context.Context, tx pg.Con
|
||||
return fmt.Errorf("cannot load organization: %w", err)
|
||||
}
|
||||
|
||||
hostname := s.svc.hostname
|
||||
baseURLParsed, err := url.Parse(s.svc.baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse base URL: %w", err)
|
||||
}
|
||||
|
||||
hostname := baseURLParsed.Host
|
||||
scheme := baseURLParsed.Scheme
|
||||
path := "/trust/" + trustCenter.Slug + "/access"
|
||||
|
||||
if organization.CustomDomainID != nil {
|
||||
@@ -456,11 +462,12 @@ func (s TrustCenterAccessService) sendAccessEmail(ctx context.Context, tx pg.Con
|
||||
}
|
||||
|
||||
hostname = customDomain.Domain
|
||||
scheme = "https"
|
||||
path = "/access"
|
||||
}
|
||||
|
||||
accessURL := url.URL{
|
||||
Scheme: "https",
|
||||
Scheme: scheme,
|
||||
Host: hostname,
|
||||
Path: path,
|
||||
RawQuery: url.Values{
|
||||
@@ -489,7 +496,7 @@ func (s TrustCenterAccessService) sendTrustCenterAccessEmail(
|
||||
accessURL string,
|
||||
) error {
|
||||
subject, textBody, htmlBody, err := emails.RenderTrustCenterAccess(
|
||||
s.svc.hostname,
|
||||
s.svc.baseURL,
|
||||
name,
|
||||
companyName,
|
||||
accessURL,
|
||||
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
"github.com/getprobo/probo/pkg/auth"
|
||||
"github.com/getprobo/probo/pkg/authz"
|
||||
"github.com/getprobo/probo/pkg/awsconfig"
|
||||
"github.com/getprobo/probo/pkg/baseurl"
|
||||
"github.com/getprobo/probo/pkg/certmanager"
|
||||
"github.com/getprobo/probo/pkg/connector"
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
@@ -64,7 +65,7 @@ type (
|
||||
}
|
||||
|
||||
config struct {
|
||||
Hostname string `json:"hostname"`
|
||||
BaseURL *baseurl.BaseURL `json:"base-url"`
|
||||
EncryptionKey cipher.EncryptionKey `json:"encryption-key"`
|
||||
Pg pgConfig `json:"pg"`
|
||||
Api apiConfig `json:"api"`
|
||||
@@ -93,7 +94,7 @@ var (
|
||||
func New() *Implm {
|
||||
return &Implm{
|
||||
cfg: config{
|
||||
Hostname: "localhost:8080",
|
||||
BaseURL: baseurl.MustParse("http://localhost:8080"),
|
||||
Api: apiConfig{
|
||||
Addr: "localhost:8080",
|
||||
},
|
||||
@@ -275,8 +276,7 @@ func (impl *Implm) Run(
|
||||
impl.cfg.EncryptionKey,
|
||||
hp,
|
||||
impl.cfg.Auth.Cookie.Secret,
|
||||
impl.cfg.Hostname,
|
||||
fmt.Sprintf("https://%s", impl.cfg.Hostname),
|
||||
impl.cfg.BaseURL.String(),
|
||||
impl.cfg.Auth.DisableSignup,
|
||||
time.Duration(impl.cfg.Auth.InvitationConfirmationTokenValidity)*time.Second,
|
||||
)
|
||||
@@ -287,7 +287,7 @@ func (impl *Implm) Run(
|
||||
authzService, err := authz.NewService(
|
||||
ctx,
|
||||
pgClient,
|
||||
impl.cfg.Hostname,
|
||||
impl.cfg.BaseURL.String(),
|
||||
impl.cfg.Auth.Cookie.Secret,
|
||||
time.Duration(impl.cfg.Auth.InvitationConfirmationTokenValidity)*time.Second,
|
||||
)
|
||||
@@ -300,7 +300,7 @@ func (impl *Implm) Run(
|
||||
samlService, err := auth.NewSAMLService(
|
||||
pgClient,
|
||||
impl.cfg.EncryptionKey,
|
||||
fmt.Sprintf("https://%s", impl.cfg.Hostname),
|
||||
impl.cfg.BaseURL.String(),
|
||||
impl.cfg.Auth.SAML.SessionDurationTime(),
|
||||
impl.cfg.Auth.Cookie.Name,
|
||||
impl.cfg.Auth.Cookie.Secret,
|
||||
@@ -347,7 +347,7 @@ func (impl *Implm) Run(
|
||||
pgClient,
|
||||
s3Client,
|
||||
impl.cfg.AWS.Bucket,
|
||||
impl.cfg.Hostname,
|
||||
impl.cfg.BaseURL.String(),
|
||||
impl.cfg.Auth.Cookie.Secret,
|
||||
trustConfig,
|
||||
agentConfig,
|
||||
@@ -366,7 +366,7 @@ func (impl *Implm) Run(
|
||||
pgClient,
|
||||
s3Client,
|
||||
impl.cfg.AWS.Bucket,
|
||||
impl.cfg.Hostname,
|
||||
impl.cfg.BaseURL.String(),
|
||||
impl.cfg.EncryptionKey,
|
||||
impl.cfg.TrustAuth.TokenSecret,
|
||||
impl.cfg.GetSlackSigningSecret(),
|
||||
@@ -392,7 +392,7 @@ func (impl *Implm) Run(
|
||||
SAML: samlService,
|
||||
ConnectorRegistry: defaultConnectorRegistry,
|
||||
Agent: agent,
|
||||
SafeRedirect: &saferedirect.SafeRedirect{AllowedHost: impl.cfg.Hostname},
|
||||
SafeRedirect: &saferedirect.SafeRedirect{AllowedHost: impl.cfg.BaseURL.Host()},
|
||||
CustomDomainCname: impl.cfg.CustomDomains.CnameTarget,
|
||||
FileManager: fileManagerService,
|
||||
PGClient: pgClient,
|
||||
|
||||
@@ -4997,12 +4997,12 @@ func (r *sAMLConfigurationResolver) SpMetadataURL(ctx context.Context, obj *type
|
||||
// TestLoginURL is the resolver for the testLoginUrl field.
|
||||
func (r *sAMLConfigurationResolver) TestLoginURL(ctx context.Context, obj *types.SAMLConfiguration) (string, error) {
|
||||
entityID := r.samlSvc.GetEntityID()
|
||||
parts := strings.Split(entityID, "/auth/saml/metadata")
|
||||
parts := strings.Split(entityID, "/connect/saml/metadata")
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("invalid entity ID format")
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/auth/saml/login/%s", parts[0], obj.ID), nil
|
||||
return fmt.Sprintf("%s/connect/saml/login/%s", parts[0], obj.ID), nil
|
||||
}
|
||||
|
||||
// Organization is the resolver for the organization field.
|
||||
|
||||
@@ -56,7 +56,7 @@ func buildOrganizationResponse(
|
||||
// Generate logo URL path if organization has a logo
|
||||
var logoURL *string
|
||||
if org.LogoFileID != nil {
|
||||
url := fmt.Sprintf("/auth/organizations/%s/logo", org.ID)
|
||||
url := fmt.Sprintf("/connect/organizations/%s/logo", org.ID)
|
||||
logoURL = &url
|
||||
}
|
||||
|
||||
@@ -74,11 +74,11 @@ func buildOrganizationResponse(
|
||||
case authsvc.AuthMethodSAML, authsvc.AuthMethodAny:
|
||||
orgResponse.AuthenticationMethod = "saml"
|
||||
if accessResult.SAMLConfig != nil {
|
||||
orgResponse.LoginURL = fmt.Sprintf("/auth/saml/login/%s", accessResult.SAMLConfig.ID)
|
||||
orgResponse.LoginURL = fmt.Sprintf("/connect/saml/login/%s", accessResult.SAMLConfig.ID)
|
||||
}
|
||||
case authsvc.AuthMethodPassword:
|
||||
orgResponse.AuthenticationMethod = "password"
|
||||
orgResponse.LoginURL = "/authentication/login?method=password"
|
||||
orgResponse.LoginURL = "/auth/login?method=password"
|
||||
}
|
||||
return orgResponse
|
||||
}
|
||||
@@ -88,13 +88,13 @@ func buildOrganizationResponse(
|
||||
|
||||
if sessionData.PasswordAuthenticated {
|
||||
orgResponse.AuthenticationMethod = "password"
|
||||
orgResponse.LoginURL = "/authentication/login?method=password"
|
||||
orgResponse.LoginURL = "/auth/login?method=password"
|
||||
} else if samlInfo, ok := sessionData.SAMLAuthenticatedOrgs[org.ID.String()]; ok {
|
||||
orgResponse.AuthenticationMethod = "saml"
|
||||
orgResponse.LoginURL = fmt.Sprintf("/auth/saml/login/%s", samlInfo.SAMLConfigID)
|
||||
orgResponse.LoginURL = fmt.Sprintf("/connect/saml/login/%s", samlInfo.SAMLConfigID)
|
||||
} else {
|
||||
orgResponse.AuthenticationMethod = "any"
|
||||
orgResponse.LoginURL = "/authentication/login?method=password"
|
||||
orgResponse.LoginURL = "/auth/login?method=password"
|
||||
}
|
||||
|
||||
return orgResponse
|
||||
|
||||
@@ -135,7 +135,7 @@ func NewServer(cfg Config) (*Server, error) {
|
||||
|
||||
func (s *Server) setupRoutes() {
|
||||
s.router.Mount("/api", s.apiServer)
|
||||
s.router.Mount("/auth", s.authServer)
|
||||
s.router.Mount("/connect", s.authServer)
|
||||
|
||||
s.router.Route("/trust/{slugOrId}", func(r chi.Router) {
|
||||
r.Use(s.loadTrustCenterBySlugOrID)
|
||||
|
||||
@@ -45,7 +45,7 @@ type (
|
||||
encryptionKey cipher.EncryptionKey
|
||||
tokenSecret string
|
||||
slackSigningSecret string
|
||||
hostname string
|
||||
baseURL string
|
||||
auth *auth.Service
|
||||
html2pdfConverter *html2pdf.Converter
|
||||
fileManager *filemanager.Service
|
||||
@@ -61,7 +61,7 @@ type (
|
||||
proboSvc *probo.Service
|
||||
encryptionKey cipher.EncryptionKey
|
||||
tokenSecret string
|
||||
hostname string
|
||||
baseURL string
|
||||
auth *auth.Service
|
||||
html2pdfConverter *html2pdf.Converter
|
||||
fileManager *filemanager.Service
|
||||
@@ -85,7 +85,7 @@ func NewService(
|
||||
pgClient *pg.Client,
|
||||
s3Client *s3.Client,
|
||||
bucket string,
|
||||
hostname string,
|
||||
baseURL string,
|
||||
encryptionKey cipher.EncryptionKey,
|
||||
tokenSecret string,
|
||||
slackSigningSecret string,
|
||||
@@ -102,7 +102,7 @@ func NewService(
|
||||
encryptionKey: encryptionKey,
|
||||
tokenSecret: tokenSecret,
|
||||
slackSigningSecret: slackSigningSecret,
|
||||
hostname: hostname,
|
||||
baseURL: baseURL,
|
||||
auth: auth,
|
||||
html2pdfConverter: html2pdfConverter,
|
||||
fileManager: fileManagerService,
|
||||
@@ -120,7 +120,7 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
|
||||
proboSvc: s.proboSvc,
|
||||
encryptionKey: s.encryptionKey,
|
||||
tokenSecret: s.tokenSecret,
|
||||
hostname: s.hostname,
|
||||
baseURL: s.baseURL,
|
||||
auth: s.auth,
|
||||
html2pdfConverter: s.html2pdfConverter,
|
||||
fileManager: s.fileManager,
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/pkg/baseurl"
|
||||
"github.com/getprobo/probo/pkg/coredata"
|
||||
"github.com/getprobo/probo/pkg/gid"
|
||||
"github.com/getprobo/probo/pkg/slack"
|
||||
@@ -409,6 +410,11 @@ func (s *SlackMessageService) buildAccessRequestMessage(
|
||||
fileIDs = append(fileIDs, file.ID)
|
||||
}
|
||||
|
||||
base, err := baseurl.Parse(s.svc.baseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse base URL: %w", err)
|
||||
}
|
||||
|
||||
templateData := struct {
|
||||
RequesterName string
|
||||
RequesterEmail string
|
||||
@@ -425,7 +431,7 @@ func (s *SlackMessageService) buildAccessRequestMessage(
|
||||
RequesterName: requesterName,
|
||||
RequesterEmail: requesterEmail,
|
||||
OrganizationID: organizationID.String(),
|
||||
Domain: s.svc.hostname,
|
||||
Domain: base.Host(),
|
||||
SlackMessageID: slackMessageID.String(),
|
||||
DocumentIDs: documentIDs,
|
||||
ReportIDs: reportIDs,
|
||||
|
||||
@@ -470,7 +470,13 @@ func (s *TrustCenterAccessService) sendAccessEmail(ctx context.Context, tx pg.Co
|
||||
return fmt.Errorf("cannot load organization: %w", err)
|
||||
}
|
||||
|
||||
hostname := s.svc.hostname
|
||||
baseURLParsed, err := url.Parse(s.svc.baseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse base URL: %w", err)
|
||||
}
|
||||
|
||||
hostname := baseURLParsed.Host
|
||||
scheme := baseURLParsed.Scheme
|
||||
path := "/trust/" + trustCenter.Slug + "/access"
|
||||
|
||||
if organization.CustomDomainID != nil {
|
||||
@@ -484,11 +490,12 @@ func (s *TrustCenterAccessService) sendAccessEmail(ctx context.Context, tx pg.Co
|
||||
}
|
||||
|
||||
hostname = customDomain.Domain
|
||||
scheme = "https"
|
||||
path = "/access"
|
||||
}
|
||||
|
||||
accessURL := url.URL{
|
||||
Scheme: "https",
|
||||
Scheme: scheme,
|
||||
Host: hostname,
|
||||
Path: path,
|
||||
RawQuery: url.Values{
|
||||
@@ -517,7 +524,7 @@ func (s *TrustCenterAccessService) sendTrustCenterAccessEmail(
|
||||
accessURL string,
|
||||
) error {
|
||||
subject, textBody, htmlBody, err := emails.RenderTrustCenterAccess(
|
||||
s.svc.hostname,
|
||||
s.svc.baseURL,
|
||||
name,
|
||||
companyName,
|
||||
accessURL,
|
||||
|
||||
Reference in New Issue
Block a user