1
0
mirror of https://github.com/jhillyerd/inbucket.git synced 2025-12-17 17:47:03 +00:00

Add configurable base path for reverse proxy use (#169)

* ui: Refactor routing functions into Router record
* ui: Store base URI in AppConfig
* ui: Use basePath in Router functions
* backend: Add Web.BasePath config option and update routes
* Tweaks to get SPA to bootstrap basePath configured
* ui: basePath support for apis/serve
* ui: basePath support for message monitor
* web: Redirect requests to / when basePath configured
* doc: add basepath to config.md
* Closes #107
This commit is contained in:
James Hillyerd
2020-08-09 15:53:15 -07:00
committed by GitHub
parent 316a732e7f
commit 289b38f016
20 changed files with 381 additions and 143 deletions

View File

@@ -25,6 +25,7 @@ import (
"github.com/inbucket/inbucket/pkg/storage" "github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/storage/file" "github.com/inbucket/inbucket/pkg/storage/file"
"github.com/inbucket/inbucket/pkg/storage/mem" "github.com/inbucket/inbucket/pkg/storage/mem"
"github.com/inbucket/inbucket/pkg/stringutil"
"github.com/inbucket/inbucket/pkg/webui" "github.com/inbucket/inbucket/pkg/webui"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@@ -129,9 +130,10 @@ func main() {
retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan) retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan)
retentionScanner.Start() retentionScanner.Start()
// Start HTTP server. // Configure routes and start HTTP server.
webui.SetupRoutes(web.Router.PathPrefix("/serve/").Subrouter()) prefix := stringutil.MakePathPrefixer(conf.Web.BasePath)
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter()) webui.SetupRoutes(web.Router.PathPrefix(prefix("/serve/")).Subrouter())
rest.SetupRoutes(web.Router.PathPrefix(prefix("/api/")).Subrouter())
web.Initialize(conf, shutdownChan, mmanager, msgHub) web.Initialize(conf, shutdownChan, mmanager, msgHub)
go web.Start(rootCtx) go web.Start(rootCtx)

View File

@@ -28,6 +28,7 @@ variables it supports:
INBUCKET_POP3_DOMAIN inbucket HELLO domain INBUCKET_POP3_DOMAIN inbucket HELLO domain
INBUCKET_POP3_TIMEOUT 600s Idle network timeout INBUCKET_POP3_TIMEOUT 600s Idle network timeout
INBUCKET_WEB_ADDR 0.0.0.0:9000 Web server IP4 host:port 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_UIDIR ui/dist User interface dir
INBUCKET_WEB_GREETINGFILE ui/greeting.html Home page greeting HTML INBUCKET_WEB_GREETINGFILE ui/greeting.html Home page greeting HTML
INBUCKET_WEB_MONITORVISIBLE true Show monitor tab in UI? 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` - 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 ### UI Directory
`INBUCKET_WEB_UIDIR` `INBUCKET_WEB_UIDIR`

View File

@@ -13,6 +13,7 @@ export INBUCKET_WEB_TEMPLATECACHE="false"
export INBUCKET_WEB_COOKIEAUTHKEY="not-secret" export INBUCKET_WEB_COOKIEAUTHKEY="not-secret"
export INBUCKET_WEB_UIDIR="ui/dist" export INBUCKET_WEB_UIDIR="ui/dist"
#export INBUCKET_WEB_MONITORVISIBLE="false" #export INBUCKET_WEB_MONITORVISIBLE="false"
#export INBUCKET_WEB_BASEPATH="prefix"
export INBUCKET_STORAGE_TYPE="file" export INBUCKET_STORAGE_TYPE="file"
export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket" export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket"
export INBUCKET_STORAGE_RETENTIONPERIOD="3h" export INBUCKET_STORAGE_RETENTIONPERIOD="3h"

View File

@@ -96,6 +96,7 @@ type POP3 struct {
// Web contains the HTTP server configuration. // Web contains the HTTP server configuration.
type Web struct { type Web struct {
Addr string `required:"true" default:"0.0.0.0:9000" desc:"Web server IP4 host:port"` 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"` UIDir string `required:"true" default:"ui/dist" desc:"User interface dir"`
GreetingFile string `required:"true" default:"ui/greeting.html" desc:"Home page greeting HTML"` 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?"` MonitorVisible bool `required:"true" default:"true" desc:"Show monitor tab in UI?"`

View File

@@ -1,5 +1,6 @@
package web package web
type jsonAppConfig struct { type jsonAppConfig struct {
MonitorVisible bool `json:"monitor-visible"` BasePath string `json:"base-path"`
MonitorVisible bool `json:"monitor-visible"`
} }

View File

@@ -16,6 +16,7 @@ import (
"github.com/inbucket/inbucket/pkg/config" "github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message" "github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/msghub" "github.com/inbucket/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/pkg/stringutil"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@@ -56,33 +57,42 @@ func Initialize(
msgHub = mh msgHub = mh
manager = mm 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. // Dynamic paths.
log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir). log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir).
Msg("Web UI content mapped") Msg("Web UI content mapped")
Router.Handle("/debug/vars", expvar.Handler()) Router.Handle(prefix("/debug/vars"), expvar.Handler())
if conf.Web.PProf { if conf.Web.PProf {
Router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) Router.HandleFunc(prefix("/debug/pprof/cmdline"), pprof.Cmdline)
Router.HandleFunc("/debug/pprof/profile", pprof.Profile) Router.HandleFunc(prefix("/debug/pprof/profile"), pprof.Profile)
Router.HandleFunc("/debug/pprof/symbol", pprof.Symbol) Router.HandleFunc(prefix("/debug/pprof/symbol"), pprof.Symbol)
Router.HandleFunc("/debug/pprof/trace", pprof.Trace) Router.HandleFunc(prefix("/debug/pprof/trace"), pprof.Trace)
Router.PathPrefix("/debug/pprof/").HandlerFunc(pprof.Index) Router.PathPrefix(prefix("/debug/pprof/")).HandlerFunc(pprof.Index)
log.Warn().Str("module", "web").Str("phase", "startup"). 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. // Static paths.
Router.PathPrefix("/static").Handler( Router.PathPrefix(prefix("/static")).Handler(
http.StripPrefix("/", http.FileServer(http.Dir(conf.Web.UIDir)))) http.StripPrefix(prefix("/"), http.FileServer(http.Dir(conf.Web.UIDir))))
Router.Path("/favicon.png").Handler( Router.Path(prefix("/favicon.png")).Handler(
fileHandler(filepath.Join(conf.Web.UIDir, "favicon.png"))) fileHandler(filepath.Join(conf.Web.UIDir, "favicon.png")))
// SPA managed paths. // SPA managed paths.
spaHandler := cookieHandler(appConfigCookie(conf.Web), spaHandler := cookieHandler(appConfigCookie(conf.Web),
fileHandler(filepath.Join(conf.Web.UIDir, "index.html"))) fileHandler(filepath.Join(conf.Web.UIDir, "index.html")))
Router.Path("/").Handler(spaHandler) Router.Path(prefix("/")).Handler(spaHandler)
Router.Path("/monitor").Handler(spaHandler) Router.Path(prefix("/monitor")).Handler(spaHandler)
Router.Path("/status").Handler(spaHandler) Router.Path(prefix("/status")).Handler(spaHandler)
Router.PathPrefix("/m/").Handler(spaHandler) Router.PathPrefix(prefix("/m/")).Handler(spaHandler)
// Error handlers. // Error handlers.
Router.NotFoundHandler = noMatchHandler( Router.NotFoundHandler = noMatchHandler(
@@ -131,6 +141,7 @@ func Start(ctx context.Context) {
func appConfigCookie(webConfig config.Web) *http.Cookie { func appConfigCookie(webConfig config.Web) *http.Cookie {
o := &jsonAppConfig{ o := &jsonAppConfig{
BasePath: webConfig.BasePath,
MonitorVisible: webConfig.MonitorVisible, MonitorVisible: webConfig.MonitorVisible,
} }
b, err := json.Marshal(o) b, err := json.Marshal(o)

View File

@@ -61,3 +61,16 @@ func SliceToLower(slice []string) {
slice[i] = strings.ToLower(s) 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
}
}

View File

@@ -1,6 +1,7 @@
package stringutil_test package stringutil_test
import ( import (
"fmt"
"net/mail" "net/mail"
"testing" "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)
}
})
}
}

View File

@@ -6,6 +6,7 @@ module Api exposing
, getServerConfig , getServerConfig
, getServerMetrics , getServerMetrics
, markMessageSeen , markMessageSeen
, monitorUri
, purgeMailbox , purgeMailbox
, serveUrl , serveUrl
) )
@@ -14,10 +15,12 @@ import Data.Message as Message exposing (Message)
import Data.MessageHeader as MessageHeader exposing (MessageHeader) import Data.MessageHeader as MessageHeader exposing (MessageHeader)
import Data.Metrics as Metrics exposing (Metrics) import Data.Metrics as Metrics exposing (Metrics)
import Data.ServerConfig as ServerConfig exposing (ServerConfig) import Data.ServerConfig as ServerConfig exposing (ServerConfig)
import Data.Session exposing (Session)
import Http import Http
import HttpUtil import HttpUtil
import Json.Decode as Decode import Json.Decode as Decode
import Json.Encode as Encode import Json.Encode as Encode
import String
import Url.Builder import Url.Builder
@@ -29,31 +32,17 @@ type alias HttpResult msg =
Result HttpUtil.Error () -> msg Result HttpUtil.Error () -> msg
{-| Builds a public REST API URL (see wiki). deleteMessage : Session -> HttpResult msg -> String -> String -> Cmd msg
-} deleteMessage session msg mailboxName id =
apiV1Url : List String -> String HttpUtil.delete msg (apiV1Url session [ "mailbox", mailboxName, id ])
apiV1Url elements =
Url.Builder.absolute ([ "api", "v1" ] ++ elements) []
{-| Builds an internal `serve` REST API URL; only used by this UI. getHeaderList : Session -> DataResult msg (List MessageHeader) -> String -> Cmd msg
-} getHeaderList session msg mailboxName =
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 =
let let
context = context =
{ method = "GET" { method = "GET"
, url = apiV1Url [ "mailbox", mailboxName ] , url = apiV1Url session [ "mailbox", mailboxName ]
} }
in in
Http.get Http.get
@@ -62,12 +51,12 @@ getHeaderList msg mailboxName =
} }
getGreeting : DataResult msg String -> Cmd msg getGreeting : Session -> DataResult msg String -> Cmd msg
getGreeting msg = getGreeting session msg =
let let
context = context =
{ method = "GET" { method = "GET"
, url = serveUrl [ "greeting" ] , url = serveUrl session [ "greeting" ]
} }
in in
Http.get Http.get
@@ -76,12 +65,12 @@ getGreeting msg =
} }
getMessage : DataResult msg Message -> String -> String -> Cmd msg getMessage : Session -> DataResult msg Message -> String -> String -> Cmd msg
getMessage msg mailboxName id = getMessage session msg mailboxName id =
let let
context = context =
{ method = "GET" { method = "GET"
, url = serveUrl [ "mailbox", mailboxName, id ] , url = serveUrl session [ "mailbox", mailboxName, id ]
} }
in in
Http.get Http.get
@@ -90,12 +79,12 @@ getMessage msg mailboxName id =
} }
getServerConfig : DataResult msg ServerConfig -> Cmd msg getServerConfig : Session -> DataResult msg ServerConfig -> Cmd msg
getServerConfig msg = getServerConfig session msg =
let let
context = context =
{ method = "GET" { method = "GET"
, url = serveUrl [ "status" ] , url = serveUrl session [ "status" ]
} }
in in
Http.get Http.get
@@ -104,12 +93,19 @@ getServerConfig msg =
} }
getServerMetrics : DataResult msg Metrics -> Cmd msg getServerMetrics : Session -> DataResult msg Metrics -> Cmd msg
getServerMetrics msg = getServerMetrics session msg =
let let
context = context =
{ method = "GET" { method = "GET"
, url = Url.Builder.absolute [ "debug", "vars" ] [] , url =
Url.Builder.absolute
(splitBasePath session.config.basePath
++ [ "debug"
, "vars"
]
)
[]
} }
in in
Http.get Http.get
@@ -118,15 +114,73 @@ getServerMetrics msg =
} }
markMessageSeen : HttpResult msg -> String -> String -> Cmd msg markMessageSeen : Session -> HttpResult msg -> String -> String -> Cmd msg
markMessageSeen msg mailboxName id = markMessageSeen session msg mailboxName id =
-- The URL tells the API which message ID to update, so we only need to indicate the -- The URL tells the API which message ID to update, so we only need to indicate the
-- desired change in the body. -- desired change in the body.
Encode.object [ ( "seen", Encode.bool True ) ] Encode.object [ ( "seen", Encode.bool True ) ]
|> Http.jsonBody |> Http.jsonBody
|> HttpUtil.patch msg (apiV1Url [ "mailbox", mailboxName, id ]) |> HttpUtil.patch msg (apiV1Url session [ "mailbox", mailboxName, id ])
purgeMailbox : HttpResult msg -> String -> Cmd msg monitorUri : Session -> String
purgeMailbox msg mailboxName = monitorUri session =
HttpUtil.delete msg (apiV1Url [ "mailbox", mailboxName ]) 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

View File

@@ -5,16 +5,18 @@ import Json.Decode.Pipeline as P
type alias AppConfig = type alias AppConfig =
{ monitorVisible : Bool { basePath : String
, monitorVisible : Bool
} }
decoder : D.Decoder AppConfig decoder : D.Decoder AppConfig
decoder = decoder =
D.succeed AppConfig D.succeed AppConfig
|> P.optional "base-path" D.string ""
|> P.required "monitor-visible" D.bool |> P.required "monitor-visible" D.bool
default : AppConfig default : AppConfig
default = default =
AppConfig True AppConfig "" True

View File

@@ -18,6 +18,7 @@ import Data.AppConfig as AppConfig exposing (AppConfig)
import Json.Decode as D import Json.Decode as D
import Json.Decode.Pipeline exposing (optional) import Json.Decode.Pipeline exposing (optional)
import Json.Encode as E import Json.Encode as E
import Route exposing (Router)
import Time import Time
import Url exposing (Url) import Url exposing (Url)
@@ -27,6 +28,7 @@ type alias Session =
, host : String , host : String
, flash : Maybe Flash , flash : Maybe Flash
, routing : Bool , routing : Bool
, router : Router
, zone : Time.Zone , zone : Time.Zone
, config : AppConfig , config : AppConfig
, persistent : Persistent , persistent : Persistent
@@ -50,6 +52,7 @@ init key location config persistent =
, host = location.host , host = location.host
, flash = Nothing , flash = Nothing
, routing = True , routing = True
, router = Route.newRouter config.basePath
, zone = Time.utc , zone = Time.utc
, config = config , config = config
, persistent = persistent , persistent = persistent
@@ -62,6 +65,7 @@ initError key location error =
, host = location.host , host = location.host
, flash = Just (Flash "Initialization failed" [ ( "Error", error ) ]) , flash = Just (Flash "Initialization failed" [ ( "Error", error ) ])
, routing = True , routing = True
, router = Route.newRouter ""
, zone = Time.utc , zone = Time.utc
, config = AppConfig.default , config = AppConfig.default
, persistent = Persistent [] , persistent = Persistent []

View File

@@ -1,5 +1,6 @@
module Layout exposing (Model, Msg, Page(..), frame, init, reset, update) module Layout exposing (Model, Msg, Page(..), frame, init, reset, update)
import Browser.Navigation as Nav
import Data.Session as Session exposing (Session) import Data.Session as Session exposing (Session)
import Html import Html
exposing exposing
@@ -132,7 +133,9 @@ update msg model session =
else else
( model ( model
, session , session
, Route.pushUrl session.key (Route.Mailbox model.mailboxName) , Route.Mailbox model.mailboxName
|> session.router.toPath
|> Nav.pushUrl session.key
) )
RecentMenuMouseOver -> RecentMenuMouseOver ->
@@ -195,14 +198,14 @@ frame { model, session, activePage, activeMailbox, modal, content } =
[ button [ class "navbar-toggle", Events.onClick (MainMenuToggled |> model.mapMsg) ] [ button [ class "navbar-toggle", Events.onClick (MainMenuToggled |> model.mapMsg) ]
[ i [ class "fas fa-bars" ] [] ] [ i [ class "fas fa-bars" ] [] ]
, span [ class "navbar-brand" ] , 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 ) ] ] , ul [ class "main-nav", classList [ ( "active", model.mainMenuVisible ) ] ]
[ if session.config.monitorVisible then [ if session.config.monitorVisible then
navbarLink Monitor Route.Monitor [ text "Monitor" ] activePage navbarLink Monitor (session.router.toPath Route.Monitor) [ text "Monitor" ] activePage
else else
text "" text ""
, navbarLink Status Route.Status [ text "Status" ] activePage , navbarLink Status (session.router.toPath Route.Status) [ text "Status" ] activePage
, navbarRecent activePage activeMailbox model session , navbarRecent activePage activeMailbox model session
, li [ class "navbar-mailbox" ] , li [ class "navbar-mailbox" ]
[ form [ Events.onSubmit (OpenMailbox |> model.mapMsg) ] [ form [ Events.onSubmit (OpenMailbox |> model.mapMsg) ]
@@ -260,10 +263,10 @@ externalLink url title =
a [ href url, target "_blank", rel "noopener" ] [ text title ] a [ href url, target "_blank", rel "noopener" ] [ text title ]
navbarLink : Page -> Route -> List (Html a) -> Page -> Html a navbarLink : Page -> String -> List (Html a) -> Page -> Html a
navbarLink page route linkContent activePage = navbarLink page url linkContent activePage =
li [ classList [ ( "navbar-active", page == 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. {-| Renders list of recent mailboxes, selecting the currently active mailbox.
@@ -292,7 +295,7 @@ navbarRecent page activeMailbox model session =
session.persistent.recentMailboxes session.persistent.recentMailboxes
recentLink mailbox = recentLink mailbox =
a [ Route.href (Route.Mailbox mailbox) ] [ text mailbox ] a [ href <| session.router.toPath <| Route.Mailbox mailbox ] [ text mailbox ]
in in
li li
[ class "navbar-dropdown-container" [ class "navbar-dropdown-container"

View File

@@ -66,7 +66,7 @@ init configValue location key =
} }
route = route =
Route.fromUrl location session.router.fromUrl location
( model, cmd ) = ( model, cmd ) =
changeRouteTo route initModel changeRouteTo route initModel
@@ -167,7 +167,7 @@ updateMain msg model session =
UrlChanged url -> UrlChanged url ->
-- Responds to new browser URL. -- Responds to new browser URL.
if session.routing then if session.routing then
changeRouteTo (Route.fromUrl url) model changeRouteTo (session.router.fromUrl url) model
else else
-- Skip once, but re-enable routing. -- Skip once, but re-enable routing.

View File

@@ -20,7 +20,7 @@ type alias Model =
init : Session -> ( Model, Cmd Msg ) init : Session -> ( Model, Cmd Msg )
init session = init session =
( Model session "", Api.getGreeting GreetingLoaded ) ( Model session "", Api.getGreeting session GreetingLoaded )

View File

@@ -1,6 +1,7 @@
module Page.Mailbox exposing (Model, Msg, init, load, subscriptions, update, view) module Page.Mailbox exposing (Model, Msg, init, load, subscriptions, update, view)
import Api import Api
import Browser.Navigation as Nav
import Data.Message as Message exposing (Message) import Data.Message as Message exposing (Message)
import Data.MessageHeader exposing (MessageHeader) import Data.MessageHeader exposing (MessageHeader)
import Data.Session as Session exposing (Session) import Data.Session as Session exposing (Session)
@@ -113,15 +114,15 @@ init session mailboxName selection =
, markSeenTimer = Timer.empty , markSeenTimer = Timer.empty
, now = Time.millisToPosix 0 , now = Time.millisToPosix 0
} }
, load mailboxName , load session mailboxName
) )
load : String -> Cmd Msg load : Session -> String -> Cmd Msg
load mailboxName = load session mailboxName =
Cmd.batch Cmd.batch
[ Task.perform Tick Time.now [ 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 ( updateSelected { model | session = Session.disableRouting model.session } id
, Cmd.batch , Cmd.batch
[ -- Update browser location. [ -- Update browser location.
Route.replaceUrl model.session.key (Route.Message model.mailboxName id) Route.Message model.mailboxName id
, Api.getMessage MessageLoaded 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 let
cmd = cmd =
Cmd.batch Cmd.batch
[ Route.replaceUrl model.session.key (Route.Mailbox model.mailboxName) [ Route.Mailbox model.mailboxName
, Api.purgeMailbox PurgedMailbox model.mailboxName |> model.session.router.toPath
|> Nav.replaceUrl model.session.key
, Api.purgeMailbox model.session PurgedMailbox model.mailboxName
] ]
in in
case model.state of case model.state of
@@ -405,8 +410,10 @@ updateDeleteMessage model message =
ShowingList (filter (\x -> x.id /= message.id) list) NoMessage ShowingList (filter (\x -> x.id /= message.id) list) NoMessage
} }
, Cmd.batch , Cmd.batch
[ Api.deleteMessage DeletedMessage message.mailbox message.id [ Api.deleteMessage model.session DeletedMessage message.mailbox message.id
, Route.replaceUrl model.session.key (Route.Mailbox model.mailboxName) , Route.Mailbox model.mailboxName
|> model.session.router.toPath
|> Nav.replaceUrl model.session.key
] ]
) )
@@ -435,7 +442,7 @@ updateMarkMessageSeen model =
| state = | state =
ShowingList newMessages (ShowingMessage { visibleMessage | seen = True }) 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 } { model | session = Session.addRecent model.mailboxName model.session }
in in
( updateSelected newModel id ( 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) -> ShowingList _ (ShowingMessage message) ->
viewMessage model.session.zone message model.bodyMode viewMessage model.session model.session.zone message model.bodyMode
ShowingList _ (Transitioning message) -> ShowingList _ (Transitioning message) ->
viewMessage model.session.zone message model.bodyMode viewMessage model.session model.session.zone message model.bodyMode
_ -> _ ->
text "" text ""
@@ -564,14 +571,14 @@ messageChip model selected message =
] ]
viewMessage : Time.Zone -> Message -> Body -> Html Msg viewMessage : Session -> Time.Zone -> Message -> Body -> Html Msg
viewMessage zone message bodyMode = viewMessage session zone message bodyMode =
let let
htmlUrl = htmlUrl =
Api.serveUrl [ "mailbox", message.mailbox, message.id, "html" ] Api.serveUrl session [ "mailbox", message.mailbox, message.id, "html" ]
sourceUrl = sourceUrl =
Api.serveUrl [ "mailbox", message.mailbox, message.id, "source" ] Api.serveUrl session [ "mailbox", message.mailbox, message.id, "source" ]
htmlButton = htmlButton =
if message.html == "" then if message.html == "" then
@@ -602,7 +609,7 @@ viewMessage zone message bodyMode =
] ]
, messageErrors message , messageErrors message
, messageBody message bodyMode , messageBody message bodyMode
, attachments message , attachments session message
] ]
@@ -665,20 +672,20 @@ messageBody message bodyMode =
] ]
attachments : Message -> Html Msg attachments : Session -> Message -> Html Msg
attachments message = attachments session message =
if List.isEmpty message.attachments then if List.isEmpty message.attachments then
div [] [] div [] []
else 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 : Session -> Message -> Message.Attachment -> Html Msg
attachmentRow message attach = attachmentRow session message attach =
let let
url = url =
Api.serveUrl Api.serveUrl session
[ "mailbox" [ "mailbox"
, message.mailbox , message.mailbox
, message.id , message.id

View File

@@ -1,5 +1,7 @@
module Page.Monitor exposing (Model, Msg, init, update, view) 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.MessageHeader as MessageHeader exposing (MessageHeader)
import Data.Session as Session exposing (Session) import Data.Session as Session exposing (Session)
import DateFormat as DF import DateFormat as DF
@@ -21,7 +23,7 @@ import Html
, thead , thead
, tr , tr
) )
import Html.Attributes exposing (class, tabindex) import Html.Attributes exposing (class, src, tabindex)
import Html.Events as Events import Html.Events as Events
import Json.Decode as D import Json.Decode as D
import Route import Route
@@ -101,7 +103,9 @@ update msg model =
openMessage : MessageHeader -> Model -> ( Model, Cmd Msg ) openMessage : MessageHeader -> Model -> ( Model, Cmd Msg )
openMessage header model = openMessage header model =
( 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" ] [ 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" , 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) , Events.on "message" (D.map MessageReceived D.value)
] ]
[] []

View File

@@ -84,7 +84,7 @@ init session =
} }
, Cmd.batch , Cmd.batch
[ Task.perform Tick Time.now [ Task.perform Tick Time.now
, Api.getServerConfig ServerConfigLoaded , Api.getServerConfig session ServerConfigLoaded
] ]
) )
@@ -134,7 +134,7 @@ update msg model =
) )
Tick time -> Tick time ->
( { model | now = time }, Api.getServerMetrics MetricsReceived ) ( { model | now = time }, Api.getServerMetrics model.session MetricsReceived )
{-| Update all metrics in Model; increment xCounter. {-| Update all metrics in Model; increment xCounter.

View File

@@ -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 exposing (Url)
import Url.Builder as Builder import Url.Builder as Builder
import Url.Parser as Parser exposing ((</>), Parser, map, oneOf, s, string, top) import Url.Parser as Parser exposing ((</>), Parser, map, oneOf, s, string, top)
@@ -17,6 +14,25 @@ type Route
| Status | 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 our application handles.
-} -}
routes : List (Parser (Route -> a) a) 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. {-| Convert route to a URI.
-} -}
routeToPath : Route -> String toPath : String -> Route -> String
routeToPath page = toPath basePath page =
let let
pieces = pieces =
case page of case page of
@@ -54,35 +86,32 @@ routeToPath page =
Status -> Status ->
[ "status" ] [ "status" ]
in 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 prepareBasePath : String -> String
fromUrl location = prepareBasePath path =
case Parser.parse (oneOf routes) location of let
Nothing -> stripSlashes str =
Unknown location.path if String.startsWith "/" str then
stripSlashes (String.dropLeft 1 str)
Just route -> else if String.endsWith "/" str then
route stripSlashes (String.dropRight 1 str)
else
str
newPath =
stripSlashes path
in
if newPath == "" then
""
else
"/" ++ newPath

View File

@@ -3,22 +3,55 @@
customElements.define( customElements.define(
'monitor-messages', 'monitor-messages',
class MonitorMessages extends HTMLElement { class MonitorMessages extends HTMLElement {
static get observedAttributes() {
return [ 'src' ]
}
constructor() { constructor() {
const self = super() super()
// TODO make URI/URL configurable. this._url = null // Current websocket URL.
var uri = '/api/v1/monitor/messages' this._socket = null // Currently open WebSocket.
self._url = ((window.location.protocol === 'https:') ? 'wss://' : 'ws://') + window.location.host + uri
self._socket = null
} }
connectedCallback() { 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 const self = this
self._socket = new WebSocket(self._url) ws.addEventListener('open', function (_e) {
var ws = self._socket
ws.addEventListener('open', function (e) {
self.dispatchEvent(new CustomEvent('connected', { detail: true })) self.dispatchEvent(new CustomEvent('connected', { detail: true }))
}) })
ws.addEventListener('close', function (e) { ws.addEventListener('close', function (_e) {
self.dispatchEvent(new CustomEvent('connected', { detail: false })) self.dispatchEvent(new CustomEvent('connected', { detail: false }))
}) })
ws.addEventListener('message', function (e) { ws.addEventListener('message', function (e) {
@@ -28,11 +61,20 @@ customElements.define(
}) })
} }
disconnectedCallback() { // Closes WebSocket connection.
var ws = this._socket wsClose() {
const ws = this._socket
if (ws) { if (ws) {
ws.close() ws.close()
} }
} }
get src() {
return this.getAttribute('src')
}
set src(value) {
this.setAttribute('src', value)
}
} }
) )

View File

@@ -6,7 +6,7 @@ module.exports = (env, argv) => {
const config = { const config = {
output: { output: {
filename: 'static/[name].[hash:8].js', filename: 'static/[name].[hash:8].js',
publicPath: '/', publicPath: '',
}, },
module: { module: {
rules: [ rules: [