mirror of
https://github.com/jhillyerd/inbucket.git
synced 2025-12-17 09:37:02 +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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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?"`
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package web
|
||||
|
||||
type jsonAppConfig struct {
|
||||
MonitorVisible bool `json:"monitor-visible"`
|
||||
BasePath string `json:"base-path"`
|
||||
MonitorVisible bool `json:"monitor-visible"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
130
ui/src/Api.elm
130
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 )
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
[]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ module.exports = (env, argv) => {
|
||||
const config = {
|
||||
output: {
|
||||
filename: 'static/[name].[hash:8].js',
|
||||
publicPath: '/',
|
||||
publicPath: '',
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
|
||||
Reference in New Issue
Block a user