diff --git a/cmd/inbucket/main.go b/cmd/inbucket/main.go index b11f259..3b70cb3 100644 --- a/cmd/inbucket/main.go +++ b/cmd/inbucket/main.go @@ -25,6 +25,7 @@ import ( "github.com/inbucket/inbucket/pkg/storage" "github.com/inbucket/inbucket/pkg/storage/file" "github.com/inbucket/inbucket/pkg/storage/mem" + "github.com/inbucket/inbucket/pkg/stringutil" "github.com/inbucket/inbucket/pkg/webui" "github.com/rs/zerolog" "github.com/rs/zerolog/log" @@ -129,9 +130,10 @@ func main() { retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan) retentionScanner.Start() - // Start HTTP server. - webui.SetupRoutes(web.Router.PathPrefix("/serve/").Subrouter()) - rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter()) + // Configure routes and start HTTP server. + prefix := stringutil.MakePathPrefixer(conf.Web.BasePath) + webui.SetupRoutes(web.Router.PathPrefix(prefix("/serve/")).Subrouter()) + rest.SetupRoutes(web.Router.PathPrefix(prefix("/api/")).Subrouter()) web.Initialize(conf, shutdownChan, mmanager, msgHub) go web.Start(rootCtx) diff --git a/doc/config.md b/doc/config.md index 089bb7a..ae4c10c 100644 --- a/doc/config.md +++ b/doc/config.md @@ -28,6 +28,7 @@ variables it supports: INBUCKET_POP3_DOMAIN inbucket HELLO domain INBUCKET_POP3_TIMEOUT 600s Idle network timeout INBUCKET_WEB_ADDR 0.0.0.0:9000 Web server IP4 host:port + INBUCKET_WEB_BASEPATH Base path prefix for UI and API URLs INBUCKET_WEB_UIDIR ui/dist User interface dir INBUCKET_WEB_GREETINGFILE ui/greeting.html Home page greeting HTML INBUCKET_WEB_MONITORVISIBLE true Show monitor tab in UI? @@ -290,6 +291,24 @@ Inbucket to listen on all available network interfaces. - Default: `0.0.0.0:9000` +### Base Path + +`INBUCKET_WEB_BASEPATH` + +Base path prefix for UI and API URLs. This option is used when you wish to +root all Inbucket URLs to a specific path when placing it behind a +reverse-proxy. + +For example, setting the base path to `prefix` will move: +- the Inbucket status page from `/status` to `/prefix/status`, +- Bob's mailbox from `/m/bob` to `/prefix/m/bob`, and +- the REST API from `/api/v1/*` to `/prefix/api/v1/*`. + +*Note:* This setting will not work correctly when running Inbucket via the npm +development server. + +- Default: None + ### UI Directory `INBUCKET_WEB_UIDIR` diff --git a/etc/dev-start.sh b/etc/dev-start.sh index 19d868e..cec0278 100755 --- a/etc/dev-start.sh +++ b/etc/dev-start.sh @@ -13,6 +13,7 @@ export INBUCKET_WEB_TEMPLATECACHE="false" export INBUCKET_WEB_COOKIEAUTHKEY="not-secret" export INBUCKET_WEB_UIDIR="ui/dist" #export INBUCKET_WEB_MONITORVISIBLE="false" +#export INBUCKET_WEB_BASEPATH="prefix" export INBUCKET_STORAGE_TYPE="file" export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket" export INBUCKET_STORAGE_RETENTIONPERIOD="3h" diff --git a/pkg/config/config.go b/pkg/config/config.go index 7ffd431..0747a33 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -96,6 +96,7 @@ type POP3 struct { // Web contains the HTTP server configuration. type Web struct { Addr string `required:"true" default:"0.0.0.0:9000" desc:"Web server IP4 host:port"` + BasePath string `default:"" desc:"Base path prefix for UI and API URLs"` UIDir string `required:"true" default:"ui/dist" desc:"User interface dir"` GreetingFile string `required:"true" default:"ui/greeting.html" desc:"Home page greeting HTML"` MonitorVisible bool `required:"true" default:"true" desc:"Show monitor tab in UI?"` diff --git a/pkg/server/web/app_json.go b/pkg/server/web/app_json.go index c80331c..e1635cd 100644 --- a/pkg/server/web/app_json.go +++ b/pkg/server/web/app_json.go @@ -1,5 +1,6 @@ package web type jsonAppConfig struct { - MonitorVisible bool `json:"monitor-visible"` + BasePath string `json:"base-path"` + MonitorVisible bool `json:"monitor-visible"` } diff --git a/pkg/server/web/server.go b/pkg/server/web/server.go index b8ac2b3..08a19e9 100644 --- a/pkg/server/web/server.go +++ b/pkg/server/web/server.go @@ -16,6 +16,7 @@ import ( "github.com/inbucket/inbucket/pkg/config" "github.com/inbucket/inbucket/pkg/message" "github.com/inbucket/inbucket/pkg/msghub" + "github.com/inbucket/inbucket/pkg/stringutil" "github.com/rs/zerolog/log" ) @@ -56,33 +57,42 @@ func Initialize( msgHub = mh manager = mm + // Redirect requests to / if there is a base path configured. + prefix := stringutil.MakePathPrefixer(conf.Web.BasePath) + redirectBase := prefix("/") + if redirectBase != "/" { + log.Info().Str("module", "web").Str("phase", "startup").Str("path", redirectBase). + Msg("Base path configured") + Router.Path("/").Handler(http.RedirectHandler(redirectBase, http.StatusFound)) + } + // Dynamic paths. log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir). Msg("Web UI content mapped") - Router.Handle("/debug/vars", expvar.Handler()) + Router.Handle(prefix("/debug/vars"), expvar.Handler()) if conf.Web.PProf { - Router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) - Router.HandleFunc("/debug/pprof/profile", pprof.Profile) - Router.HandleFunc("/debug/pprof/symbol", pprof.Symbol) - Router.HandleFunc("/debug/pprof/trace", pprof.Trace) - Router.PathPrefix("/debug/pprof/").HandlerFunc(pprof.Index) + Router.HandleFunc(prefix("/debug/pprof/cmdline"), pprof.Cmdline) + Router.HandleFunc(prefix("/debug/pprof/profile"), pprof.Profile) + Router.HandleFunc(prefix("/debug/pprof/symbol"), pprof.Symbol) + Router.HandleFunc(prefix("/debug/pprof/trace"), pprof.Trace) + Router.PathPrefix(prefix("/debug/pprof/")).HandlerFunc(pprof.Index) log.Warn().Str("module", "web").Str("phase", "startup"). - Msg("Go pprof tools installed to /debug/pprof") + Msg("Go pprof tools installed to " + prefix("/debug/pprof")) } // Static paths. - Router.PathPrefix("/static").Handler( - http.StripPrefix("/", http.FileServer(http.Dir(conf.Web.UIDir)))) - Router.Path("/favicon.png").Handler( + Router.PathPrefix(prefix("/static")).Handler( + http.StripPrefix(prefix("/"), http.FileServer(http.Dir(conf.Web.UIDir)))) + Router.Path(prefix("/favicon.png")).Handler( fileHandler(filepath.Join(conf.Web.UIDir, "favicon.png"))) // SPA managed paths. spaHandler := cookieHandler(appConfigCookie(conf.Web), fileHandler(filepath.Join(conf.Web.UIDir, "index.html"))) - Router.Path("/").Handler(spaHandler) - Router.Path("/monitor").Handler(spaHandler) - Router.Path("/status").Handler(spaHandler) - Router.PathPrefix("/m/").Handler(spaHandler) + Router.Path(prefix("/")).Handler(spaHandler) + Router.Path(prefix("/monitor")).Handler(spaHandler) + Router.Path(prefix("/status")).Handler(spaHandler) + Router.PathPrefix(prefix("/m/")).Handler(spaHandler) // Error handlers. Router.NotFoundHandler = noMatchHandler( @@ -131,6 +141,7 @@ func Start(ctx context.Context) { func appConfigCookie(webConfig config.Web) *http.Cookie { o := &jsonAppConfig{ + BasePath: webConfig.BasePath, MonitorVisible: webConfig.MonitorVisible, } b, err := json.Marshal(o) diff --git a/pkg/stringutil/utils.go b/pkg/stringutil/utils.go index cae5fbd..67647f6 100644 --- a/pkg/stringutil/utils.go +++ b/pkg/stringutil/utils.go @@ -61,3 +61,16 @@ func SliceToLower(slice []string) { slice[i] = strings.ToLower(s) } } + +// MakePathPrefixer returns a function that will add the specified prefix (base) to URI strings. +// The returned prefixer expects all provided paths to start with /. +func MakePathPrefixer(prefix string) func(string) string { + prefix = strings.Trim(prefix, "/") + if prefix != "" { + prefix = "/" + prefix + } + + return func(path string) string { + return prefix + path + } +} diff --git a/pkg/stringutil/utils_test.go b/pkg/stringutil/utils_test.go index cf6df32..8163736 100644 --- a/pkg/stringutil/utils_test.go +++ b/pkg/stringutil/utils_test.go @@ -1,6 +1,7 @@ package stringutil_test import ( + "fmt" "net/mail" "testing" @@ -35,3 +36,43 @@ func TestStringAddressList(t *testing.T) { } } } + +func TestMakePathPrefixer(t *testing.T) { + testCases := []struct { + prefix, path, want string + }{ + {prefix: "", path: "", want: ""}, + {prefix: "", path: "relative", want: "relative"}, + {prefix: "", path: "/qualified", want: "/qualified"}, + {prefix: "", path: "/many/path/segments", want: "/many/path/segments"}, + {prefix: "pfx", path: "", want: "/pfx"}, + {prefix: "pfx", path: "/", want: "/pfx/"}, + {prefix: "pfx", path: "relative", want: "/pfxrelative"}, + {prefix: "pfx", path: "/qualified", want: "/pfx/qualified"}, + {prefix: "pfx", path: "/many/path/segments", want: "/pfx/many/path/segments"}, + {prefix: "/pfx/", path: "", want: "/pfx"}, + {prefix: "/pfx/", path: "/", want: "/pfx/"}, + {prefix: "/pfx/", path: "relative", want: "/pfxrelative"}, + {prefix: "/pfx/", path: "/qualified", want: "/pfx/qualified"}, + {prefix: "/pfx/", path: "/many/path/segments", want: "/pfx/many/path/segments"}, + {prefix: "a/b/c", path: "", want: "/a/b/c"}, + {prefix: "a/b/c", path: "/", want: "/a/b/c/"}, + {prefix: "a/b/c", path: "relative", want: "/a/b/crelative"}, + {prefix: "a/b/c", path: "/qualified", want: "/a/b/c/qualified"}, + {prefix: "a/b/c", path: "/many/path/segments", want: "/a/b/c/many/path/segments"}, + {prefix: "/a/b/c/", path: "", want: "/a/b/c"}, + {prefix: "/a/b/c/", path: "/", want: "/a/b/c/"}, + {prefix: "/a/b/c/", path: "relative", want: "/a/b/crelative"}, + {prefix: "/a/b/c/", path: "/qualified", want: "/a/b/c/qualified"}, + {prefix: "/a/b/c/", path: "/many/path/segments", want: "/a/b/c/many/path/segments"}, + } + for _, tc := range testCases { + t.Run(fmt.Sprintf("prefix %s for path %s", tc.prefix, tc.path), func(t *testing.T) { + prefixer := stringutil.MakePathPrefixer(tc.prefix) + got := prefixer(tc.path) + if got != tc.want { + t.Errorf("Got: %q, want: %q", got, tc.want) + } + }) + } +} diff --git a/ui/src/Api.elm b/ui/src/Api.elm index 058552d..2edd3cb 100644 --- a/ui/src/Api.elm +++ b/ui/src/Api.elm @@ -6,6 +6,7 @@ module Api exposing , getServerConfig , getServerMetrics , markMessageSeen + , monitorUri , purgeMailbox , serveUrl ) @@ -14,10 +15,12 @@ import Data.Message as Message exposing (Message) import Data.MessageHeader as MessageHeader exposing (MessageHeader) import Data.Metrics as Metrics exposing (Metrics) import Data.ServerConfig as ServerConfig exposing (ServerConfig) +import Data.Session exposing (Session) import Http import HttpUtil import Json.Decode as Decode import Json.Encode as Encode +import String import Url.Builder @@ -29,31 +32,17 @@ type alias HttpResult msg = Result HttpUtil.Error () -> msg -{-| Builds a public REST API URL (see wiki). --} -apiV1Url : List String -> String -apiV1Url elements = - Url.Builder.absolute ([ "api", "v1" ] ++ elements) [] +deleteMessage : Session -> HttpResult msg -> String -> String -> Cmd msg +deleteMessage session msg mailboxName id = + HttpUtil.delete msg (apiV1Url session [ "mailbox", mailboxName, id ]) -{-| Builds an internal `serve` REST API URL; only used by this UI. --} -serveUrl : List String -> String -serveUrl elements = - Url.Builder.absolute ("serve" :: elements) [] - - -deleteMessage : HttpResult msg -> String -> String -> Cmd msg -deleteMessage msg mailboxName id = - HttpUtil.delete msg (apiV1Url [ "mailbox", mailboxName, id ]) - - -getHeaderList : DataResult msg (List MessageHeader) -> String -> Cmd msg -getHeaderList msg mailboxName = +getHeaderList : Session -> DataResult msg (List MessageHeader) -> String -> Cmd msg +getHeaderList session msg mailboxName = let context = { method = "GET" - , url = apiV1Url [ "mailbox", mailboxName ] + , url = apiV1Url session [ "mailbox", mailboxName ] } in Http.get @@ -62,12 +51,12 @@ getHeaderList msg mailboxName = } -getGreeting : DataResult msg String -> Cmd msg -getGreeting msg = +getGreeting : Session -> DataResult msg String -> Cmd msg +getGreeting session msg = let context = { method = "GET" - , url = serveUrl [ "greeting" ] + , url = serveUrl session [ "greeting" ] } in Http.get @@ -76,12 +65,12 @@ getGreeting msg = } -getMessage : DataResult msg Message -> String -> String -> Cmd msg -getMessage msg mailboxName id = +getMessage : Session -> DataResult msg Message -> String -> String -> Cmd msg +getMessage session msg mailboxName id = let context = { method = "GET" - , url = serveUrl [ "mailbox", mailboxName, id ] + , url = serveUrl session [ "mailbox", mailboxName, id ] } in Http.get @@ -90,12 +79,12 @@ getMessage msg mailboxName id = } -getServerConfig : DataResult msg ServerConfig -> Cmd msg -getServerConfig msg = +getServerConfig : Session -> DataResult msg ServerConfig -> Cmd msg +getServerConfig session msg = let context = { method = "GET" - , url = serveUrl [ "status" ] + , url = serveUrl session [ "status" ] } in Http.get @@ -104,12 +93,19 @@ getServerConfig msg = } -getServerMetrics : DataResult msg Metrics -> Cmd msg -getServerMetrics msg = +getServerMetrics : Session -> DataResult msg Metrics -> Cmd msg +getServerMetrics session msg = let context = { method = "GET" - , url = Url.Builder.absolute [ "debug", "vars" ] [] + , url = + Url.Builder.absolute + (splitBasePath session.config.basePath + ++ [ "debug" + , "vars" + ] + ) + [] } in Http.get @@ -118,15 +114,73 @@ getServerMetrics msg = } -markMessageSeen : HttpResult msg -> String -> String -> Cmd msg -markMessageSeen msg mailboxName id = +markMessageSeen : Session -> HttpResult msg -> String -> String -> Cmd msg +markMessageSeen session msg mailboxName id = -- The URL tells the API which message ID to update, so we only need to indicate the -- desired change in the body. Encode.object [ ( "seen", Encode.bool True ) ] |> Http.jsonBody - |> HttpUtil.patch msg (apiV1Url [ "mailbox", mailboxName, id ]) + |> HttpUtil.patch msg (apiV1Url session [ "mailbox", mailboxName, id ]) -purgeMailbox : HttpResult msg -> String -> Cmd msg -purgeMailbox msg mailboxName = - HttpUtil.delete msg (apiV1Url [ "mailbox", mailboxName ]) +monitorUri : Session -> String +monitorUri session = + apiV1Url session [ "monitor", "messages" ] + + +purgeMailbox : Session -> HttpResult msg -> String -> Cmd msg +purgeMailbox session msg mailboxName = + HttpUtil.delete msg (apiV1Url session [ "mailbox", mailboxName ]) + + +{-| Builds a public REST API URL (see wiki). +-} +apiV1Url : Session -> List String -> String +apiV1Url session elements = + Url.Builder.absolute + (List.concat + [ splitBasePath session.config.basePath + , [ "api", "v1" ] + , elements + ] + ) + [] + + +{-| Builds an internal `serve` REST API URL; only used by this UI. +-} +serveUrl : Session -> List String -> String +serveUrl session elements = + Url.Builder.absolute + (List.concat + [ splitBasePath session.config.basePath + , [ "serve" ] + , elements + ] + ) + [] + + +{-| Converts base path into a list of path elements. +-} +splitBasePath : String -> List String +splitBasePath path = + if path == "" then + [] + + else + let + stripSlashes str = + if String.startsWith "/" str then + stripSlashes (String.dropLeft 1 str) + + else if String.endsWith "/" str then + stripSlashes (String.dropRight 1 str) + + else + str + + newPath = + stripSlashes path + in + String.split "/" newPath diff --git a/ui/src/Data/AppConfig.elm b/ui/src/Data/AppConfig.elm index b18701f..fa983ed 100644 --- a/ui/src/Data/AppConfig.elm +++ b/ui/src/Data/AppConfig.elm @@ -5,16 +5,18 @@ import Json.Decode.Pipeline as P type alias AppConfig = - { monitorVisible : Bool + { basePath : String + , monitorVisible : Bool } decoder : D.Decoder AppConfig decoder = D.succeed AppConfig + |> P.optional "base-path" D.string "" |> P.required "monitor-visible" D.bool default : AppConfig default = - AppConfig True + AppConfig "" True diff --git a/ui/src/Data/Session.elm b/ui/src/Data/Session.elm index 02ade26..c192f02 100644 --- a/ui/src/Data/Session.elm +++ b/ui/src/Data/Session.elm @@ -18,6 +18,7 @@ import Data.AppConfig as AppConfig exposing (AppConfig) import Json.Decode as D import Json.Decode.Pipeline exposing (optional) import Json.Encode as E +import Route exposing (Router) import Time import Url exposing (Url) @@ -27,6 +28,7 @@ type alias Session = , host : String , flash : Maybe Flash , routing : Bool + , router : Router , zone : Time.Zone , config : AppConfig , persistent : Persistent @@ -50,6 +52,7 @@ init key location config persistent = , host = location.host , flash = Nothing , routing = True + , router = Route.newRouter config.basePath , zone = Time.utc , config = config , persistent = persistent @@ -62,6 +65,7 @@ initError key location error = , host = location.host , flash = Just (Flash "Initialization failed" [ ( "Error", error ) ]) , routing = True + , router = Route.newRouter "" , zone = Time.utc , config = AppConfig.default , persistent = Persistent [] diff --git a/ui/src/Layout.elm b/ui/src/Layout.elm index 3231312..16d4c21 100644 --- a/ui/src/Layout.elm +++ b/ui/src/Layout.elm @@ -1,5 +1,6 @@ module Layout exposing (Model, Msg, Page(..), frame, init, reset, update) +import Browser.Navigation as Nav import Data.Session as Session exposing (Session) import Html exposing @@ -132,7 +133,9 @@ update msg model session = else ( model , session - , Route.pushUrl session.key (Route.Mailbox model.mailboxName) + , Route.Mailbox model.mailboxName + |> session.router.toPath + |> Nav.pushUrl session.key ) RecentMenuMouseOver -> @@ -195,14 +198,14 @@ frame { model, session, activePage, activeMailbox, modal, content } = [ button [ class "navbar-toggle", Events.onClick (MainMenuToggled |> model.mapMsg) ] [ i [ class "fas fa-bars" ] [] ] , span [ class "navbar-brand" ] - [ a [ Route.href Route.Home ] [ text "@ inbucket" ] ] + [ a [ href <| session.router.toPath Route.Home ] [ text "@ inbucket" ] ] , ul [ class "main-nav", classList [ ( "active", model.mainMenuVisible ) ] ] [ if session.config.monitorVisible then - navbarLink Monitor Route.Monitor [ text "Monitor" ] activePage + navbarLink Monitor (session.router.toPath Route.Monitor) [ text "Monitor" ] activePage else text "" - , navbarLink Status Route.Status [ text "Status" ] activePage + , navbarLink Status (session.router.toPath Route.Status) [ text "Status" ] activePage , navbarRecent activePage activeMailbox model session , li [ class "navbar-mailbox" ] [ form [ Events.onSubmit (OpenMailbox |> model.mapMsg) ] @@ -260,10 +263,10 @@ externalLink url title = a [ href url, target "_blank", rel "noopener" ] [ text title ] -navbarLink : Page -> Route -> List (Html a) -> Page -> Html a -navbarLink page route linkContent activePage = +navbarLink : Page -> String -> List (Html a) -> Page -> Html a +navbarLink page url linkContent activePage = li [ classList [ ( "navbar-active", page == activePage ) ] ] - [ a [ Route.href route ] linkContent ] + [ a [ href url ] linkContent ] {-| Renders list of recent mailboxes, selecting the currently active mailbox. @@ -292,7 +295,7 @@ navbarRecent page activeMailbox model session = session.persistent.recentMailboxes recentLink mailbox = - a [ Route.href (Route.Mailbox mailbox) ] [ text mailbox ] + a [ href <| session.router.toPath <| Route.Mailbox mailbox ] [ text mailbox ] in li [ class "navbar-dropdown-container" diff --git a/ui/src/Main.elm b/ui/src/Main.elm index 7099576..30a9698 100644 --- a/ui/src/Main.elm +++ b/ui/src/Main.elm @@ -66,7 +66,7 @@ init configValue location key = } route = - Route.fromUrl location + session.router.fromUrl location ( model, cmd ) = changeRouteTo route initModel @@ -167,7 +167,7 @@ updateMain msg model session = UrlChanged url -> -- Responds to new browser URL. if session.routing then - changeRouteTo (Route.fromUrl url) model + changeRouteTo (session.router.fromUrl url) model else -- Skip once, but re-enable routing. diff --git a/ui/src/Page/Home.elm b/ui/src/Page/Home.elm index 832902b..b85d291 100644 --- a/ui/src/Page/Home.elm +++ b/ui/src/Page/Home.elm @@ -20,7 +20,7 @@ type alias Model = init : Session -> ( Model, Cmd Msg ) init session = - ( Model session "", Api.getGreeting GreetingLoaded ) + ( Model session "", Api.getGreeting session GreetingLoaded ) diff --git a/ui/src/Page/Mailbox.elm b/ui/src/Page/Mailbox.elm index ce52308..7ef4ac4 100644 --- a/ui/src/Page/Mailbox.elm +++ b/ui/src/Page/Mailbox.elm @@ -1,6 +1,7 @@ module Page.Mailbox exposing (Model, Msg, init, load, subscriptions, update, view) import Api +import Browser.Navigation as Nav import Data.Message as Message exposing (Message) import Data.MessageHeader exposing (MessageHeader) import Data.Session as Session exposing (Session) @@ -113,15 +114,15 @@ init session mailboxName selection = , markSeenTimer = Timer.empty , now = Time.millisToPosix 0 } - , load mailboxName + , load session mailboxName ) -load : String -> Cmd Msg -load mailboxName = +load : Session -> String -> Cmd Msg +load session mailboxName = Cmd.batch [ Task.perform Tick Time.now - , Api.getHeaderList ListLoaded mailboxName + , Api.getHeaderList session ListLoaded mailboxName ] @@ -165,8 +166,10 @@ update msg model = ( updateSelected { model | session = Session.disableRouting model.session } id , Cmd.batch [ -- Update browser location. - Route.replaceUrl model.session.key (Route.Message model.mailboxName id) - , Api.getMessage MessageLoaded model.mailboxName id + Route.Message model.mailboxName id + |> model.session.router.toPath + |> Nav.replaceUrl model.session.key + , Api.getMessage model.session MessageLoaded model.mailboxName id ] ) @@ -322,8 +325,10 @@ updateTriggerPurge model = let cmd = Cmd.batch - [ Route.replaceUrl model.session.key (Route.Mailbox model.mailboxName) - , Api.purgeMailbox PurgedMailbox model.mailboxName + [ Route.Mailbox model.mailboxName + |> model.session.router.toPath + |> Nav.replaceUrl model.session.key + , Api.purgeMailbox model.session PurgedMailbox model.mailboxName ] in case model.state of @@ -405,8 +410,10 @@ updateDeleteMessage model message = ShowingList (filter (\x -> x.id /= message.id) list) NoMessage } , Cmd.batch - [ Api.deleteMessage DeletedMessage message.mailbox message.id - , Route.replaceUrl model.session.key (Route.Mailbox model.mailboxName) + [ Api.deleteMessage model.session DeletedMessage message.mailbox message.id + , Route.Mailbox model.mailboxName + |> model.session.router.toPath + |> Nav.replaceUrl model.session.key ] ) @@ -435,7 +442,7 @@ updateMarkMessageSeen model = | state = ShowingList newMessages (ShowingMessage { visibleMessage | seen = True }) } - , Api.markMessageSeen MarkSeenLoaded visibleMessage.mailbox visibleMessage.id + , Api.markMessageSeen model.session MarkSeenLoaded visibleMessage.mailbox visibleMessage.id ) _ -> @@ -449,7 +456,7 @@ updateOpenMessage model id = { model | session = Session.addRecent model.mailboxName model.session } in ( updateSelected newModel id - , Api.getMessage MessageLoaded model.mailboxName id + , Api.getMessage model.session MessageLoaded model.mailboxName id ) @@ -503,10 +510,10 @@ view model = ) ShowingList _ (ShowingMessage message) -> - viewMessage model.session.zone message model.bodyMode + viewMessage model.session model.session.zone message model.bodyMode ShowingList _ (Transitioning message) -> - viewMessage model.session.zone message model.bodyMode + viewMessage model.session model.session.zone message model.bodyMode _ -> text "" @@ -564,14 +571,14 @@ messageChip model selected message = ] -viewMessage : Time.Zone -> Message -> Body -> Html Msg -viewMessage zone message bodyMode = +viewMessage : Session -> Time.Zone -> Message -> Body -> Html Msg +viewMessage session zone message bodyMode = let htmlUrl = - Api.serveUrl [ "mailbox", message.mailbox, message.id, "html" ] + Api.serveUrl session [ "mailbox", message.mailbox, message.id, "html" ] sourceUrl = - Api.serveUrl [ "mailbox", message.mailbox, message.id, "source" ] + Api.serveUrl session [ "mailbox", message.mailbox, message.id, "source" ] htmlButton = if message.html == "" then @@ -602,7 +609,7 @@ viewMessage zone message bodyMode = ] , messageErrors message , messageBody message bodyMode - , attachments message + , attachments session message ] @@ -665,20 +672,20 @@ messageBody message bodyMode = ] -attachments : Message -> Html Msg -attachments message = +attachments : Session -> Message -> Html Msg +attachments session message = if List.isEmpty message.attachments then div [] [] else - table [ class "attachments well" ] (List.map (attachmentRow message) message.attachments) + table [ class "attachments well" ] (List.map (attachmentRow session message) message.attachments) -attachmentRow : Message -> Message.Attachment -> Html Msg -attachmentRow message attach = +attachmentRow : Session -> Message -> Message.Attachment -> Html Msg +attachmentRow session message attach = let url = - Api.serveUrl + Api.serveUrl session [ "mailbox" , message.mailbox , message.id diff --git a/ui/src/Page/Monitor.elm b/ui/src/Page/Monitor.elm index b0bc696..86cd699 100644 --- a/ui/src/Page/Monitor.elm +++ b/ui/src/Page/Monitor.elm @@ -1,5 +1,7 @@ module Page.Monitor exposing (Model, Msg, init, update, view) +import Api +import Browser.Navigation as Nav import Data.MessageHeader as MessageHeader exposing (MessageHeader) import Data.Session as Session exposing (Session) import DateFormat as DF @@ -21,7 +23,7 @@ import Html , thead , tr ) -import Html.Attributes exposing (class, tabindex) +import Html.Attributes exposing (class, src, tabindex) import Html.Events as Events import Json.Decode as D import Route @@ -101,7 +103,9 @@ update msg model = openMessage : MessageHeader -> Model -> ( Model, Cmd Msg ) openMessage header model = ( model - , Route.pushUrl model.session.key (Route.Message header.mailbox header.id) + , Route.Message header.mailbox header.id + |> model.session.router.toPath + |> Nav.replaceUrl model.session.key ) @@ -132,8 +136,12 @@ view model = [ button [ Events.onClick Clear ] [ text "Clear" ] ] ] + + -- monitor-messages maintains a websocket connection to the Inbucket daemon at the path + -- specified by `src`. , node "monitor-messages" - [ Events.on "connected" (D.map Connected <| D.at [ "detail" ] <| D.bool) + [ src (Api.monitorUri model.session) + , Events.on "connected" (D.map Connected <| D.at [ "detail" ] <| D.bool) , Events.on "message" (D.map MessageReceived D.value) ] [] diff --git a/ui/src/Page/Status.elm b/ui/src/Page/Status.elm index 00ead55..e1b6781 100644 --- a/ui/src/Page/Status.elm +++ b/ui/src/Page/Status.elm @@ -84,7 +84,7 @@ init session = } , Cmd.batch [ Task.perform Tick Time.now - , Api.getServerConfig ServerConfigLoaded + , Api.getServerConfig session ServerConfigLoaded ] ) @@ -134,7 +134,7 @@ update msg model = ) Tick time -> - ( { model | now = time }, Api.getServerMetrics MetricsReceived ) + ( { model | now = time }, Api.getServerMetrics model.session MetricsReceived ) {-| Update all metrics in Model; increment xCounter. diff --git a/ui/src/Route.elm b/ui/src/Route.elm index 271b972..44ce368 100644 --- a/ui/src/Route.elm +++ b/ui/src/Route.elm @@ -1,8 +1,5 @@ -module Route exposing (Route(..), fromUrl, href, pushUrl, replaceUrl) +module Route exposing (Route(..), Router, newRouter) -import Browser.Navigation as Navigation exposing (Key) -import Html exposing (Attribute) -import Html.Attributes as Attr import Url exposing (Url) import Url.Builder as Builder import Url.Parser as Parser exposing ((), Parser, map, oneOf, s, string, top) @@ -17,6 +14,25 @@ type Route | Status +type alias Router = + { fromUrl : Url -> Route + , toPath : Route -> String + } + + +{-| Returns a configured Router. +-} +newRouter : String -> Router +newRouter basePath = + let + newPath = + prepareBasePath basePath + in + { fromUrl = fromUrl newPath + , toPath = toPath newPath + } + + {-| Routes our application handles. -} routes : List (Parser (Route -> a) a) @@ -29,10 +45,26 @@ routes = ] +{-| Returns the Route for a given URL. +-} +fromUrl : String -> Url -> Route +fromUrl basePath url = + let + relative = + { url | path = String.replace basePath "" url.path } + in + case Parser.parse (oneOf routes) relative of + Nothing -> + Unknown url.path + + Just route -> + route + + {-| Convert route to a URI. -} -routeToPath : Route -> String -routeToPath page = +toPath : String -> Route -> String +toPath basePath page = let pieces = case page of @@ -54,35 +86,32 @@ routeToPath page = Status -> [ "status" ] in - Builder.absolute pieces [] + basePath ++ Builder.absolute pieces [] +{-| Make sure basePath starts with a slash and does not have trailing slashes. --- PUBLIC HELPERS +"inbucket/" becomes "/inbucket", "" remains "" - -href : Route -> Attribute msg -href route = - Attr.href (routeToPath route) - - -replaceUrl : Key -> Route -> Cmd msg -replaceUrl key = - routeToPath >> Navigation.replaceUrl key - - -pushUrl : Key -> Route -> Cmd msg -pushUrl key = - routeToPath >> Navigation.pushUrl key - - -{-| Returns the Route for a given URL. -} -fromUrl : Url -> Route -fromUrl location = - case Parser.parse (oneOf routes) location of - Nothing -> - Unknown location.path +prepareBasePath : String -> String +prepareBasePath path = + let + stripSlashes str = + if String.startsWith "/" str then + stripSlashes (String.dropLeft 1 str) - Just route -> - route + else if String.endsWith "/" str then + stripSlashes (String.dropRight 1 str) + + else + str + + newPath = + stripSlashes path + in + if newPath == "" then + "" + + else + "/" ++ newPath diff --git a/ui/src/monitorMessages.js b/ui/src/monitorMessages.js index c606b6d..58ac9d2 100644 --- a/ui/src/monitorMessages.js +++ b/ui/src/monitorMessages.js @@ -3,22 +3,55 @@ customElements.define( 'monitor-messages', class MonitorMessages extends HTMLElement { + static get observedAttributes() { + return [ 'src' ] + } + constructor() { - const self = super() - // TODO make URI/URL configurable. - var uri = '/api/v1/monitor/messages' - self._url = ((window.location.protocol === 'https:') ? 'wss://' : 'ws://') + window.location.host + uri - self._socket = null + super() + this._url = null // Current websocket URL. + this._socket = null // Currently open WebSocket. } connectedCallback() { + if (this.hasAttribute('src')) { + this.wsOpen(this.getAttribute('src')) + } + } + + attributeChangedCallback() { + // Checking _socket prevents connection attempts prior to connectedCallback(). + if (this._socket && this.hasAttribute('src')) { + this.wsOpen(this.getAttribute('src')) + } + } + + disconnectedCallback() { + this.wsClose() + } + + // Connects to WebSocket and registers event listeners. + wsOpen(uri) { + const url = + ((window.location.protocol === 'https:') ? 'wss://' : 'ws://') + + window.location.host + uri + if (this._socket && url === this._url) { + // Already connected to same URL. + return + } + this.wsClose() + this._url = url + + console.info("Connecting to WebSocket", url) + const ws = new WebSocket(url) + this._socket = ws + + // Register event listeners. const self = this - self._socket = new WebSocket(self._url) - var ws = self._socket - ws.addEventListener('open', function (e) { + ws.addEventListener('open', function (_e) { self.dispatchEvent(new CustomEvent('connected', { detail: true })) }) - ws.addEventListener('close', function (e) { + ws.addEventListener('close', function (_e) { self.dispatchEvent(new CustomEvent('connected', { detail: false })) }) ws.addEventListener('message', function (e) { @@ -28,11 +61,20 @@ customElements.define( }) } - disconnectedCallback() { - var ws = this._socket + // Closes WebSocket connection. + wsClose() { + const ws = this._socket if (ws) { ws.close() } } + + get src() { + return this.getAttribute('src') + } + + set src(value) { + this.setAttribute('src', value) + } } ) diff --git a/ui/webpack.config.js b/ui/webpack.config.js index 79b1af4..2310dc8 100644 --- a/ui/webpack.config.js +++ b/ui/webpack.config.js @@ -6,7 +6,7 @@ module.exports = (env, argv) => { const config = { output: { filename: 'static/[name].[hash:8].js', - publicPath: '/', + publicPath: '', }, module: { rules: [