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

@@ -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

View File

@@ -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

View File

@@ -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 []

View File

@@ -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"

View File

@@ -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.

View File

@@ -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 )

View File

@@ -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

View File

@@ -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)
]
[]

View File

@@ -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.

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.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

View File

@@ -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)
}
}
)