mirror of
https://github.com/jhillyerd/inbucket.git
synced 2025-12-17 09:37:02 +00:00
ui: Much elm work, such wow
- ui: Fix favicon - webui: Changes to support serving Elm UI - Static files now served from `/` mount point. - Old UI handlers moved to `/serve` mount point, some will still be needed by the Elm UI; safe HTML and attachments for example. - Update dev-start.sh for new UI, with tip on how to build it. - ui: Detect browser host:port for websocket URL, - webui: Remove unused mailbox handlers, rename routes - Many routes not needed by Elm UI. - `/serve/mailbox/*` becomes `/serve/m/*`. - webui: Impl custom JSON message API for web UI, - ui: Refactor Mailbox view functions, - ui: Add body tabs for safe HTML and plain text, - webui: Format plain text for new UI, - ui: List attachments with view & download links,
This commit is contained in:
@@ -124,9 +124,9 @@ func main() {
|
|||||||
retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan)
|
retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan)
|
||||||
retentionScanner.Start()
|
retentionScanner.Start()
|
||||||
// Start HTTP server.
|
// Start HTTP server.
|
||||||
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
webui.SetupRoutes(web.Router.PathPrefix("/serve/").Subrouter())
|
||||||
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||||
webui.SetupRoutes(web.Router)
|
web.Initialize(conf, shutdownChan, mmanager, msgHub)
|
||||||
go web.Start(rootCtx)
|
go web.Start(rootCtx)
|
||||||
// Start POP3 server.
|
// Start POP3 server.
|
||||||
pop3Server := pop3.New(conf.POP3, shutdownChan, store)
|
pop3Server := pop3.New(conf.POP3, shutdownChan, store)
|
||||||
|
|||||||
@@ -6,13 +6,21 @@ export INBUCKET_LOGLEVEL="debug"
|
|||||||
export INBUCKET_SMTP_DISCARDDOMAINS="bitbucket.local"
|
export INBUCKET_SMTP_DISCARDDOMAINS="bitbucket.local"
|
||||||
export INBUCKET_WEB_TEMPLATECACHE="false"
|
export INBUCKET_WEB_TEMPLATECACHE="false"
|
||||||
export INBUCKET_WEB_COOKIEAUTHKEY="not-secret"
|
export INBUCKET_WEB_COOKIEAUTHKEY="not-secret"
|
||||||
|
export INBUCKET_WEB_UIDIR="ui/build"
|
||||||
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="15m"
|
export INBUCKET_STORAGE_RETENTIONPERIOD="3h"
|
||||||
|
|
||||||
if ! test -x ./inbucket; then
|
if ! test -x ./inbucket; then
|
||||||
echo "$PWD/inbucket not found/executable!" >&2
|
echo "$PWD/inbucket not found/executable!" >&2
|
||||||
echo "Run this script from the inbucket root directory after running make" >&2
|
echo "Run this script from the inbucket root directory after running make." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
index="$INBUCKET_WEB_UIDIR/index.html"
|
||||||
|
if ! test -f "$index"; then
|
||||||
|
echo "$index does not exist!" >&2
|
||||||
|
echo "Run 'elm-app build' from the 'ui' directory." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ func setupWebServer(mm message.Manager) *bytes.Buffer {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
shutdownChan := make(chan bool)
|
shutdownChan := make(chan bool)
|
||||||
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
|
|
||||||
SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
|
||||||
|
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
|
||||||
|
|
||||||
return buf
|
return buf
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,11 +54,11 @@ func Reverse(name string, things ...interface{}) string {
|
|||||||
|
|
||||||
// TextToHTML takes plain text, escapes it and tries to pretty it up for
|
// TextToHTML takes plain text, escapes it and tries to pretty it up for
|
||||||
// HTML display
|
// HTML display
|
||||||
func TextToHTML(text string) template.HTML {
|
func TextToHTML(text string) string {
|
||||||
text = html.EscapeString(text)
|
text = html.EscapeString(text)
|
||||||
text = urlRE.ReplaceAllStringFunc(text, WrapURL)
|
text = urlRE.ReplaceAllStringFunc(text, WrapURL)
|
||||||
replacer := strings.NewReplacer("\r\n", "<br/>\n", "\r", "<br/>\n", "\n", "<br/>\n")
|
replacer := strings.NewReplacer("\r\n", "<br/>\n", "\r", "<br/>\n", "\n", "<br/>\n")
|
||||||
return template.HTML(replacer.Replace(text))
|
return replacer.Replace(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WrapURL wraps a <a href> tag around the provided URL
|
// WrapURL wraps a <a href> tag around the provided URL
|
||||||
|
|||||||
@@ -1,30 +1,55 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTextToHtml(t *testing.T) {
|
func TestTextToHtml(t *testing.T) {
|
||||||
// Identity
|
testCases := []struct {
|
||||||
assert.Equal(t, TextToHTML("html"), template.HTML("html"))
|
input, want string
|
||||||
|
}{
|
||||||
// Check it escapes
|
{
|
||||||
assert.Equal(t, TextToHTML("<html>"), template.HTML("<html>"))
|
input: "html",
|
||||||
|
want: "html",
|
||||||
// Check for linebreaks
|
},
|
||||||
assert.Equal(t, TextToHTML("line\nbreak"), template.HTML("line<br/>\nbreak"))
|
// Check it escapes.
|
||||||
assert.Equal(t, TextToHTML("line\r\nbreak"), template.HTML("line<br/>\nbreak"))
|
{
|
||||||
assert.Equal(t, TextToHTML("line\rbreak"), template.HTML("line<br/>\nbreak"))
|
input: "<html>",
|
||||||
}
|
want: "<html>",
|
||||||
|
},
|
||||||
func TestURLDetection(t *testing.T) {
|
// Check for linebreaks.
|
||||||
assert.Equal(t,
|
{
|
||||||
TextToHTML("http://google.com/"),
|
input: "line\nbreak",
|
||||||
template.HTML("<a href=\"http://google.com/\" target=\"_blank\">http://google.com/</a>"))
|
want: "line<br/>\nbreak",
|
||||||
assert.Equal(t,
|
},
|
||||||
TextToHTML("http://a.com/?q=a&n=v"),
|
{
|
||||||
template.HTML("<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&n=v</a>"))
|
input: "line\r\nbreak",
|
||||||
|
want: "line<br/>\nbreak",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "line\rbreak",
|
||||||
|
want: "line<br/>\nbreak",
|
||||||
|
},
|
||||||
|
// Check URL detection.
|
||||||
|
{
|
||||||
|
input: "http://google.com/",
|
||||||
|
want: "<a href=\"http://google.com/\" target=\"_blank\">http://google.com/</a>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "http://a.com/?q=a&n=v",
|
||||||
|
want: "<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&n=v</a>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
input: "(http://a.com/?q=a&n=v)",
|
||||||
|
want: "(<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&n=v</a>)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.input, func(t *testing.T) {
|
||||||
|
got := TextToHTML(tc.input)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("TextToHTML(%q)\ngot : %q\nwant: %q", tc.input, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/pprof"
|
"net/http/pprof"
|
||||||
"path/filepath"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
@@ -66,11 +65,8 @@ func Initialize(
|
|||||||
manager = mm
|
manager = mm
|
||||||
|
|
||||||
// Content Paths
|
// Content Paths
|
||||||
staticPath := filepath.Join(conf.Web.UIDir, staticDir)
|
|
||||||
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.PathPrefix("/public/").Handler(http.StripPrefix("/public/",
|
|
||||||
http.FileServer(http.Dir(staticPath))))
|
|
||||||
Router.Handle("/debug/vars", expvar.Handler())
|
Router.Handle("/debug/vars", expvar.Handler())
|
||||||
if conf.Web.PProf {
|
if conf.Web.PProf {
|
||||||
Router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
Router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||||
@@ -81,6 +77,8 @@ func Initialize(
|
|||||||
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 /debug/pprof")
|
||||||
}
|
}
|
||||||
|
// If no other route matches, attempt to service as UI element.
|
||||||
|
Router.PathPrefix("/").Handler(http.StripPrefix("/", http.FileServer(http.Dir(conf.Web.UIDir))))
|
||||||
|
|
||||||
// Session cookie setup
|
// Session cookie setup
|
||||||
if conf.Web.CookieAuthKey == "" {
|
if conf.Web.CookieAuthKey == "" {
|
||||||
|
|||||||
@@ -6,139 +6,88 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
"github.com/jhillyerd/inbucket/pkg/server/web"
|
||||||
"github.com/jhillyerd/inbucket/pkg/storage"
|
"github.com/jhillyerd/inbucket/pkg/storage"
|
||||||
|
"github.com/jhillyerd/inbucket/pkg/stringutil"
|
||||||
"github.com/jhillyerd/inbucket/pkg/webui/sanitize"
|
"github.com/jhillyerd/inbucket/pkg/webui/sanitize"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MailboxIndex renders the index page for a particular mailbox
|
// JSONMessage formats message data for the UI.
|
||||||
func MailboxIndex(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
type JSONMessage struct {
|
||||||
// Form values must be validated manually
|
Mailbox string `json:"mailbox"`
|
||||||
name := req.FormValue("name")
|
ID string `json:"id"`
|
||||||
selected := req.FormValue("id")
|
From string `json:"from"`
|
||||||
if len(name) == 0 {
|
To []string `json:"to"`
|
||||||
ctx.Session.AddFlash("Account name is required", "errors")
|
Subject string `json:"subject"`
|
||||||
_ = ctx.Session.Save(req, w)
|
Date time.Time `json:"date"`
|
||||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
Size int64 `json:"size"`
|
||||||
return nil
|
Seen bool `json:"seen"`
|
||||||
}
|
Header map[string][]string `json:"header"`
|
||||||
name, err = ctx.Manager.MailboxForAddress(name)
|
Text string `json:"text"`
|
||||||
if err != nil {
|
HTML string `json:"html"`
|
||||||
ctx.Session.AddFlash(err.Error(), "errors")
|
Attachments []*JSONAttachment `json:"attachments"`
|
||||||
_ = ctx.Session.Save(req, w)
|
|
||||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Remember this mailbox was visited
|
|
||||||
RememberMailbox(ctx, name)
|
|
||||||
// Get flash messages, save session
|
|
||||||
errorFlash := ctx.Session.Flashes("errors")
|
|
||||||
if err = ctx.Session.Save(req, w); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Render template
|
|
||||||
return web.RenderTemplate("mailbox/index.html", w, map[string]interface{}{
|
|
||||||
"ctx": ctx,
|
|
||||||
"errorFlash": errorFlash,
|
|
||||||
"name": name,
|
|
||||||
"selected": selected,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MailboxIndexFriendly handles pretty links to a particular mailbox. Renders a redirect
|
// JSONAttachment formats attachment data for the UI.
|
||||||
func MailboxIndexFriendly(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
type JSONAttachment struct {
|
||||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
ID string `json:"id"`
|
||||||
if err != nil {
|
FileName string `json:"filename"`
|
||||||
ctx.Session.AddFlash(err.Error(), "errors")
|
ContentType string `json:"content-type"`
|
||||||
_ = ctx.Session.Save(req, w)
|
|
||||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Build redirect
|
|
||||||
uri := fmt.Sprintf("%s?name=%s", web.Reverse("MailboxIndex"), name)
|
|
||||||
http.Redirect(w, req, uri, http.StatusSeeOther)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MailboxLink handles pretty links to a particular message. Renders a redirect
|
// MailboxMessage outputs a particular message as JSON for the UI.
|
||||||
func MailboxLink(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
func MailboxMessage(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
|
||||||
id := ctx.Vars["id"]
|
|
||||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
|
||||||
if err != nil {
|
|
||||||
ctx.Session.AddFlash(err.Error(), "errors")
|
|
||||||
_ = ctx.Session.Save(req, w)
|
|
||||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Build redirect
|
|
||||||
uri := fmt.Sprintf("%s?name=%s&id=%s", web.Reverse("MailboxIndex"), name, id)
|
|
||||||
http.Redirect(w, req, uri, http.StatusSeeOther)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MailboxList renders a list of messages in a mailbox. Renders a partial
|
|
||||||
func MailboxList(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
|
||||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
|
||||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
messages, err := ctx.Manager.GetMetadata(name)
|
|
||||||
if err != nil {
|
|
||||||
// This doesn't indicate empty, likely an IO error
|
|
||||||
return fmt.Errorf("Failed to get messages for %v: %v", name, err)
|
|
||||||
}
|
|
||||||
// Render partial template
|
|
||||||
return web.RenderPartial("mailbox/_list.html", w, map[string]interface{}{
|
|
||||||
"ctx": ctx,
|
|
||||||
"name": name,
|
|
||||||
"messages": messages,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// MailboxShow renders a particular message from a mailbox. Renders an HTML partial
|
|
||||||
func MailboxShow(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
|
||||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
|
||||||
id := ctx.Vars["id"]
|
id := ctx.Vars["id"]
|
||||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
msg, err := ctx.Manager.GetMessage(name, id)
|
msg, err := ctx.Manager.GetMessage(name, id)
|
||||||
if err == storage.ErrNotExist {
|
if err != nil && err != storage.ErrNotExist {
|
||||||
|
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
||||||
|
}
|
||||||
|
if msg == nil {
|
||||||
http.NotFound(w, req)
|
http.NotFound(w, req)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
attachParts := msg.Attachments()
|
||||||
// This doesn't indicate empty, likely an IO error
|
attachments := make([]*JSONAttachment, len(attachParts))
|
||||||
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
for i, part := range attachParts {
|
||||||
|
attachments[i] = &JSONAttachment{
|
||||||
|
ID: strconv.Itoa(i),
|
||||||
|
FileName: part.FileName,
|
||||||
|
ContentType: part.ContentType,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
body := template.HTML(web.TextToHTML(msg.Text()))
|
// Sanitize HTML body.
|
||||||
htmlAvailable := msg.HTML() != ""
|
htmlBody := ""
|
||||||
var htmlBody template.HTML
|
if msg.HTML() != "" {
|
||||||
if htmlAvailable {
|
|
||||||
if str, err := sanitize.HTML(msg.HTML()); err == nil {
|
if str, err := sanitize.HTML(msg.HTML()); err == nil {
|
||||||
htmlBody = template.HTML(str)
|
htmlBody = str
|
||||||
} else {
|
} else {
|
||||||
// Soft failure, render empty tab.
|
htmlBody = "Inbucket HTML sanitizer failed."
|
||||||
log.Warn().Str("module", "webui").Str("mailbox", name).Str("id", id).Err(err).
|
log.Warn().Str("module", "webui").Str("mailbox", name).Str("id", id).Err(err).
|
||||||
Msg("HTML sanitizer failed")
|
Msg("HTML sanitizer failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Render partial template
|
return web.RenderJSON(w,
|
||||||
return web.RenderPartial("mailbox/_show.html", w, map[string]interface{}{
|
&JSONMessage{
|
||||||
"ctx": ctx,
|
Mailbox: name,
|
||||||
"name": name,
|
ID: msg.ID,
|
||||||
"message": msg,
|
From: msg.From.String(),
|
||||||
"body": body,
|
To: stringutil.StringAddressList(msg.To),
|
||||||
"htmlAvailable": htmlAvailable,
|
Subject: msg.Subject,
|
||||||
"htmlBody": htmlBody,
|
Date: msg.Date,
|
||||||
"mimeErrors": msg.MIMEErrors(),
|
Size: msg.Size,
|
||||||
"attachments": msg.Attachments(),
|
Seen: msg.Seen,
|
||||||
})
|
Header: msg.Header(),
|
||||||
|
Text: web.TextToHTML(msg.Text()),
|
||||||
|
HTML: htmlBody,
|
||||||
|
Attachments: attachments,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// MailboxHTML displays the HTML content of a message. Renders a partial
|
// MailboxHTML displays the HTML content of a message. Renders a partial
|
||||||
@@ -191,48 +140,6 @@ func MailboxSource(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// MailboxDownloadAttach sends the attachment to the client; disposition:
|
|
||||||
// attachment, type: application/octet-stream
|
|
||||||
func MailboxDownloadAttach(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
|
||||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
|
||||||
id := ctx.Vars["id"]
|
|
||||||
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
|
|
||||||
if err != nil {
|
|
||||||
ctx.Session.AddFlash(err.Error(), "errors")
|
|
||||||
_ = ctx.Session.Save(req, w)
|
|
||||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
numStr := ctx.Vars["num"]
|
|
||||||
num, err := strconv.ParseUint(numStr, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
ctx.Session.AddFlash("Attachment number must be unsigned numeric", "errors")
|
|
||||||
_ = ctx.Session.Save(req, w)
|
|
||||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
msg, err := ctx.Manager.GetMessage(name, id)
|
|
||||||
if err == storage.ErrNotExist {
|
|
||||||
http.NotFound(w, req)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
// This doesn't indicate empty, likely an IO error
|
|
||||||
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
|
|
||||||
}
|
|
||||||
if int(num) >= len(msg.Attachments()) {
|
|
||||||
ctx.Session.AddFlash("Attachment number too high", "errors")
|
|
||||||
_ = ctx.Session.Save(req, w)
|
|
||||||
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Output attachment
|
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
|
||||||
w.Header().Set("Content-Disposition", "attachment")
|
|
||||||
_, err = w.Write(msg.Attachments()[num].Content)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// MailboxViewAttach sends the attachment to the client for online viewing
|
// MailboxViewAttach sends the attachment to the client for online viewing
|
||||||
func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
|
||||||
// Don't have to validate these aren't empty, Gorilla returns 404
|
// Don't have to validate these aren't empty, Gorilla returns 404
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"github.com/jhillyerd/inbucket/pkg/server/web"
|
"github.com/jhillyerd/inbucket/pkg/server/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetupRoutes populates routes for the webui into the provided Router
|
// SetupRoutes populates routes for the webui into the provided Router.
|
||||||
func SetupRoutes(r *mux.Router) {
|
func SetupRoutes(r *mux.Router) {
|
||||||
r.Path("/").Handler(
|
r.Path("/").Handler(
|
||||||
web.Handler(RootIndex)).Name("RootIndex").Methods("GET")
|
web.Handler(RootIndex)).Name("RootIndex").Methods("GET")
|
||||||
@@ -16,22 +16,12 @@ func SetupRoutes(r *mux.Router) {
|
|||||||
web.Handler(RootMonitorMailbox)).Name("RootMonitorMailbox").Methods("GET")
|
web.Handler(RootMonitorMailbox)).Name("RootMonitorMailbox").Methods("GET")
|
||||||
r.Path("/status").Handler(
|
r.Path("/status").Handler(
|
||||||
web.Handler(RootStatus)).Name("RootStatus").Methods("GET")
|
web.Handler(RootStatus)).Name("RootStatus").Methods("GET")
|
||||||
r.Path("/link/{name}/{id}").Handler(
|
r.Path("/m/{name}/{id}").Handler(
|
||||||
web.Handler(MailboxLink)).Name("MailboxLink").Methods("GET")
|
web.Handler(MailboxMessage)).Name("MailboxMessage").Methods("GET")
|
||||||
r.Path("/mailbox").Handler(
|
r.Path("/m/{name}/{id}/html").Handler(
|
||||||
web.Handler(MailboxIndex)).Name("MailboxIndex").Methods("GET")
|
|
||||||
r.Path("/mailbox/{name}").Handler(
|
|
||||||
web.Handler(MailboxList)).Name("MailboxList").Methods("GET")
|
|
||||||
r.Path("/mailbox/{name}/{id}").Handler(
|
|
||||||
web.Handler(MailboxShow)).Name("MailboxShow").Methods("GET")
|
|
||||||
r.Path("/mailbox/{name}/{id}/html").Handler(
|
|
||||||
web.Handler(MailboxHTML)).Name("MailboxHtml").Methods("GET")
|
web.Handler(MailboxHTML)).Name("MailboxHtml").Methods("GET")
|
||||||
r.Path("/mailbox/{name}/{id}/source").Handler(
|
r.Path("/m/{name}/{id}/source").Handler(
|
||||||
web.Handler(MailboxSource)).Name("MailboxSource").Methods("GET")
|
web.Handler(MailboxSource)).Name("MailboxSource").Methods("GET")
|
||||||
r.Path("/mailbox/dattach/{name}/{id}/{num}/{file}").Handler(
|
r.Path("/m/attach/{name}/{id}/{num}/{file}").Handler(
|
||||||
web.Handler(MailboxDownloadAttach)).Name("MailboxDownloadAttach").Methods("GET")
|
|
||||||
r.Path("/mailbox/vattach/{name}/{id}/{num}/{file}").Handler(
|
|
||||||
web.Handler(MailboxViewAttach)).Name("MailboxViewAttach").Methods("GET")
|
web.Handler(MailboxViewAttach)).Name("MailboxViewAttach").Methods("GET")
|
||||||
r.Path("/{name}").Handler(
|
|
||||||
web.Handler(MailboxIndexFriendly)).Name("MailboxListFriendly").Methods("GET")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
},
|
},
|
||||||
"/debug": {
|
"/debug": {
|
||||||
"target": "http://localhost:9000"
|
"target": "http://localhost:9000"
|
||||||
|
},
|
||||||
|
"/serve": {
|
||||||
|
"target": "http://localhost:9000"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -10,8 +10,8 @@
|
|||||||
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
|
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
|
||||||
-->
|
-->
|
||||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
|
||||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
|
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png" type="image/png">
|
||||||
<title>Elm App</title>
|
<title>Inbucket</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>
|
<noscript>
|
||||||
|
|||||||
@@ -13,13 +13,16 @@ type alias Message =
|
|||||||
, date : String
|
, date : String
|
||||||
, size : Int
|
, size : Int
|
||||||
, seen : Bool
|
, seen : Bool
|
||||||
, body : Body
|
, text : String
|
||||||
|
, html : String
|
||||||
|
, attachments : List Attachment
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
type alias Body =
|
type alias Attachment =
|
||||||
{ text : String
|
{ id : String
|
||||||
, html : String
|
, fileName : String
|
||||||
|
, contentType : String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -34,11 +37,14 @@ decoder =
|
|||||||
|> required "date" string
|
|> required "date" string
|
||||||
|> required "size" int
|
|> required "size" int
|
||||||
|> required "seen" bool
|
|> required "seen" bool
|
||||||
|> required "body" bodyDecoder
|
|
||||||
|
|
||||||
|
|
||||||
bodyDecoder : Decoder Body
|
|
||||||
bodyDecoder =
|
|
||||||
decode Body
|
|
||||||
|> required "text" string
|
|> required "text" string
|
||||||
|> required "html" string
|
|> required "html" string
|
||||||
|
|> required "attachments" (list attachmentDecoder)
|
||||||
|
|
||||||
|
|
||||||
|
attachmentDecoder : Decoder Attachment
|
||||||
|
attachmentDecoder =
|
||||||
|
decode Attachment
|
||||||
|
|> required "id" string
|
||||||
|
|> required "filename" string
|
||||||
|
|> required "content-type" string
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ module Data.Session
|
|||||||
|
|
||||||
import Json.Decode as Decode exposing (..)
|
import Json.Decode as Decode exposing (..)
|
||||||
import Json.Decode.Pipeline exposing (..)
|
import Json.Decode.Pipeline exposing (..)
|
||||||
|
import Navigation exposing (Location)
|
||||||
|
|
||||||
|
|
||||||
type alias Session =
|
type alias Session =
|
||||||
{ flash : String
|
{ host : String
|
||||||
|
, flash : String
|
||||||
, routing : Bool
|
, routing : Bool
|
||||||
, persistent : Persistent
|
, persistent : Persistent
|
||||||
}
|
}
|
||||||
@@ -35,9 +37,9 @@ type Msg
|
|||||||
| AddRecent String
|
| AddRecent String
|
||||||
|
|
||||||
|
|
||||||
init : Persistent -> Session
|
init : Location -> Persistent -> Session
|
||||||
init persistent =
|
init location persistent =
|
||||||
Session "" True persistent
|
Session location.host "" True persistent
|
||||||
|
|
||||||
|
|
||||||
update : Msg -> Session -> Session
|
update : Msg -> Session -> Session
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ init : Value -> Location -> ( Model, Cmd Msg )
|
|||||||
init sessionValue location =
|
init sessionValue location =
|
||||||
let
|
let
|
||||||
session =
|
session =
|
||||||
Session.init (Session.decodeValueWithDefault sessionValue)
|
Session.init location (Session.decodeValueWithDefault sessionValue)
|
||||||
|
|
||||||
model =
|
model =
|
||||||
{ page = Home Home.init
|
{ page = Home Home.init
|
||||||
@@ -197,7 +197,7 @@ setRoute route model =
|
|||||||
)
|
)
|
||||||
|
|
||||||
Route.Monitor ->
|
Route.Monitor ->
|
||||||
( { model | page = Monitor Monitor.init }
|
( { model | page = Monitor (Monitor.init model.session.host) }
|
||||||
, Ports.windowTitle "Inbucket Monitor"
|
, Ports.windowTitle "Inbucket Monitor"
|
||||||
, Session.none
|
, Session.none
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,21 +5,21 @@ import Data.MessageHeader as MessageHeader exposing (MessageHeader)
|
|||||||
import Data.Session as Session exposing (Session)
|
import Data.Session as Session exposing (Session)
|
||||||
import Json.Decode as Decode exposing (Decoder)
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
import Html exposing (..)
|
import Html exposing (..)
|
||||||
import Html.Attributes exposing (class, classList, href, id, placeholder, target)
|
import Html.Attributes exposing (class, classList, downloadAs, href, id, property, target)
|
||||||
import Html.Events exposing (..)
|
import Html.Events exposing (..)
|
||||||
import Http exposing (Error)
|
import Http exposing (Error)
|
||||||
import HttpUtil
|
import HttpUtil
|
||||||
|
import Json.Encode exposing (string)
|
||||||
import Ports
|
import Ports
|
||||||
import Route exposing (Route)
|
import Route exposing (Route)
|
||||||
|
|
||||||
|
|
||||||
inbucketBase : String
|
-- MODEL
|
||||||
inbucketBase =
|
|
||||||
""
|
|
||||||
|
|
||||||
|
|
||||||
|
type Body
|
||||||
-- MODEL --
|
= TextBody
|
||||||
|
| SafeHtmlBody
|
||||||
|
|
||||||
|
|
||||||
type alias Model =
|
type alias Model =
|
||||||
@@ -27,12 +27,13 @@ type alias Model =
|
|||||||
, selected : Maybe String
|
, selected : Maybe String
|
||||||
, headers : List MessageHeader
|
, headers : List MessageHeader
|
||||||
, message : Maybe Message
|
, message : Maybe Message
|
||||||
|
, bodyMode : Body
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
init : String -> Maybe String -> Model
|
init : String -> Maybe String -> Model
|
||||||
init name id =
|
init name id =
|
||||||
Model name id [] Nothing
|
Model name id [] Nothing SafeHtmlBody
|
||||||
|
|
||||||
|
|
||||||
load : String -> Cmd Msg
|
load : String -> Cmd Msg
|
||||||
@@ -44,7 +45,7 @@ load name =
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- UPDATE --
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
type Msg
|
type Msg
|
||||||
@@ -52,8 +53,9 @@ type Msg
|
|||||||
| ViewMessage String
|
| ViewMessage String
|
||||||
| DeleteMessage Message
|
| DeleteMessage Message
|
||||||
| DeleteMessageResult (Result Http.Error ())
|
| DeleteMessageResult (Result Http.Error ())
|
||||||
| NewMailbox (Result Http.Error (List MessageHeader))
|
| MailboxResult (Result Http.Error (List MessageHeader))
|
||||||
| NewMessage (Result Http.Error Message)
|
| MessageResult (Result Http.Error Message)
|
||||||
|
| MessageBody Body
|
||||||
|
|
||||||
|
|
||||||
update : Session -> Msg -> Model -> ( Model, Cmd Msg, Session.Msg )
|
update : Session -> Msg -> Model -> ( Model, Cmd Msg, Session.Msg )
|
||||||
@@ -83,7 +85,7 @@ update session msg model =
|
|||||||
DeleteMessageResult (Err err) ->
|
DeleteMessageResult (Err err) ->
|
||||||
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
|
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
|
||||||
|
|
||||||
NewMailbox (Ok headers) ->
|
MailboxResult (Ok headers) ->
|
||||||
let
|
let
|
||||||
newModel =
|
newModel =
|
||||||
{ model | headers = headers }
|
{ model | headers = headers }
|
||||||
@@ -96,31 +98,47 @@ update session msg model =
|
|||||||
-- Recurse to select message id.
|
-- Recurse to select message id.
|
||||||
update session (ViewMessage id) newModel
|
update session (ViewMessage id) newModel
|
||||||
|
|
||||||
NewMailbox (Err err) ->
|
MailboxResult (Err err) ->
|
||||||
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
|
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
|
||||||
|
|
||||||
NewMessage (Ok msg) ->
|
MessageResult (Ok msg) ->
|
||||||
( { model | message = Just msg }, Cmd.none, Session.none )
|
let
|
||||||
|
bodyMode =
|
||||||
|
if msg.html == "" then
|
||||||
|
TextBody
|
||||||
|
else
|
||||||
|
model.bodyMode
|
||||||
|
in
|
||||||
|
( { model
|
||||||
|
| message = Just msg
|
||||||
|
, bodyMode = bodyMode
|
||||||
|
}
|
||||||
|
, Cmd.none
|
||||||
|
, Session.none
|
||||||
|
)
|
||||||
|
|
||||||
NewMessage (Err err) ->
|
MessageResult (Err err) ->
|
||||||
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
|
( model, Cmd.none, Session.SetFlash (HttpUtil.errorString err) )
|
||||||
|
|
||||||
|
MessageBody bodyMode ->
|
||||||
|
( { model | bodyMode = bodyMode }, Cmd.none, Session.none )
|
||||||
|
|
||||||
|
|
||||||
getMailbox : String -> Cmd Msg
|
getMailbox : String -> Cmd Msg
|
||||||
getMailbox name =
|
getMailbox name =
|
||||||
let
|
let
|
||||||
url =
|
url =
|
||||||
inbucketBase ++ "/api/v1/mailbox/" ++ name
|
"/api/v1/mailbox/" ++ name
|
||||||
in
|
in
|
||||||
Http.get url (Decode.list MessageHeader.decoder)
|
Http.get url (Decode.list MessageHeader.decoder)
|
||||||
|> Http.send NewMailbox
|
|> Http.send MailboxResult
|
||||||
|
|
||||||
|
|
||||||
deleteMessage : Model -> Message -> ( Model, Cmd Msg, Session.Msg )
|
deleteMessage : Model -> Message -> ( Model, Cmd Msg, Session.Msg )
|
||||||
deleteMessage model msg =
|
deleteMessage model msg =
|
||||||
let
|
let
|
||||||
url =
|
url =
|
||||||
inbucketBase ++ "/api/v1/mailbox/" ++ msg.mailbox ++ "/" ++ msg.id
|
"/api/v1/mailbox/" ++ msg.mailbox ++ "/" ++ msg.id
|
||||||
|
|
||||||
cmd =
|
cmd =
|
||||||
HttpUtil.delete url
|
HttpUtil.delete url
|
||||||
@@ -140,35 +158,46 @@ getMessage : String -> String -> Cmd Msg
|
|||||||
getMessage mailbox id =
|
getMessage mailbox id =
|
||||||
let
|
let
|
||||||
url =
|
url =
|
||||||
inbucketBase ++ "/api/v1/mailbox/" ++ mailbox ++ "/" ++ id
|
"/serve/m/" ++ mailbox ++ "/" ++ id
|
||||||
in
|
in
|
||||||
Http.get url Message.decoder
|
Http.get url Message.decoder
|
||||||
|> Http.send NewMessage
|
|> Http.send MessageResult
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- VIEW --
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
view : Session -> Model -> Html Msg
|
view : Session -> Model -> Html Msg
|
||||||
view session model =
|
view session model =
|
||||||
div [ id "page", class "mailbox" ]
|
div [ id "page", class "mailbox" ]
|
||||||
[ aside [ id "message-list" ] [ viewMailbox model ]
|
[ aside [ id "message-list" ] [ messageList model ]
|
||||||
, main_ [ id "message" ] [ viewMessage model ]
|
, main_
|
||||||
|
[ id "message" ]
|
||||||
|
[ case model.message of
|
||||||
|
Just message ->
|
||||||
|
viewMessage message model.bodyMode
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
text
|
||||||
|
("Select a message on the left,"
|
||||||
|
++ " or enter a different username into the box on upper right."
|
||||||
|
)
|
||||||
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
viewMailbox : Model -> Html Msg
|
messageList : Model -> Html Msg
|
||||||
viewMailbox model =
|
messageList model =
|
||||||
div [] (List.map (viewHeader model) (List.reverse model.headers))
|
div [] (List.map (messageChip model.selected) (List.reverse model.headers))
|
||||||
|
|
||||||
|
|
||||||
viewHeader : Model -> MessageHeader -> Html Msg
|
messageChip : Maybe String -> MessageHeader -> Html Msg
|
||||||
viewHeader mailbox msg =
|
messageChip selected msg =
|
||||||
div
|
div
|
||||||
[ classList
|
[ classList
|
||||||
[ ( "message-list-entry", True )
|
[ ( "message-list-entry", True )
|
||||||
, ( "selected", mailbox.selected == Just msg.id )
|
, ( "selected", selected == Just msg.id )
|
||||||
, ( "unseen", not msg.seen )
|
, ( "unseen", not msg.seen )
|
||||||
]
|
]
|
||||||
, onClick (ClickMessage msg.id)
|
, onClick (ClickMessage msg.id)
|
||||||
@@ -179,38 +208,92 @@ viewHeader mailbox msg =
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
viewMessage : Model -> Html Msg
|
viewMessage : Message -> Body -> Html Msg
|
||||||
viewMessage model =
|
viewMessage message bodyMode =
|
||||||
case model.message of
|
let
|
||||||
Just message ->
|
sourceUrl message =
|
||||||
div []
|
"/serve/m/" ++ message.mailbox ++ "/" ++ message.id ++ "/source"
|
||||||
[ div [ class "button-bar" ]
|
in
|
||||||
[ button [ class "danger", onClick (DeleteMessage message) ] [ text "Delete" ]
|
div []
|
||||||
, a
|
[ div [ class "button-bar" ]
|
||||||
[ href
|
[ button [ class "danger", onClick (DeleteMessage message) ] [ text "Delete" ]
|
||||||
(inbucketBase
|
, a
|
||||||
++ "/mailbox/"
|
[ href (sourceUrl message), target "_blank" ]
|
||||||
++ message.mailbox
|
[ button [] [ text "Source" ] ]
|
||||||
++ "/"
|
|
||||||
++ message.id
|
|
||||||
++ "/source"
|
|
||||||
)
|
|
||||||
, target "_blank"
|
|
||||||
]
|
|
||||||
[ button [] [ text "Source" ] ]
|
|
||||||
]
|
|
||||||
, dl [ id "message-header" ]
|
|
||||||
[ dt [] [ text "From:" ]
|
|
||||||
, dd [] [ text message.from ]
|
|
||||||
, dt [] [ text "To:" ]
|
|
||||||
, dd [] (List.map text message.to)
|
|
||||||
, dt [] [ text "Date:" ]
|
|
||||||
, dd [] [ text message.date ]
|
|
||||||
, dt [] [ text "Subject:" ]
|
|
||||||
, dd [] [ text message.subject ]
|
|
||||||
]
|
|
||||||
, article [] [ text message.body.text ]
|
|
||||||
]
|
]
|
||||||
|
, dl [ id "message-header" ]
|
||||||
|
[ dt [] [ text "From:" ]
|
||||||
|
, dd [] [ text message.from ]
|
||||||
|
, dt [] [ text "To:" ]
|
||||||
|
, dd [] (List.map text message.to)
|
||||||
|
, dt [] [ text "Date:" ]
|
||||||
|
, dd [] [ text message.date ]
|
||||||
|
, dt [] [ text "Subject:" ]
|
||||||
|
, dd [] [ text message.subject ]
|
||||||
|
]
|
||||||
|
, messageBody message bodyMode
|
||||||
|
, attachments message
|
||||||
|
]
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
text ""
|
messageBody : Message -> Body -> Html Msg
|
||||||
|
messageBody message bodyMode =
|
||||||
|
let
|
||||||
|
bodyModeTab mode label =
|
||||||
|
a
|
||||||
|
[ classList [ ( "active", bodyMode == mode ) ]
|
||||||
|
, onClick (MessageBody mode)
|
||||||
|
, href "javacript:void(0)"
|
||||||
|
]
|
||||||
|
[ text label ]
|
||||||
|
|
||||||
|
safeHtml =
|
||||||
|
bodyModeTab SafeHtmlBody "Safe HTML"
|
||||||
|
|
||||||
|
plainText =
|
||||||
|
bodyModeTab TextBody "Plain Text"
|
||||||
|
|
||||||
|
tabs =
|
||||||
|
if message.html == "" then
|
||||||
|
[ plainText ]
|
||||||
|
else
|
||||||
|
[ safeHtml, plainText ]
|
||||||
|
in
|
||||||
|
div [ class "tab-panel" ]
|
||||||
|
[ nav [ class "tab-bar" ] tabs
|
||||||
|
, article [ class "message-body" ]
|
||||||
|
[ case bodyMode of
|
||||||
|
SafeHtmlBody ->
|
||||||
|
div [ property "innerHTML" (string message.html) ] []
|
||||||
|
|
||||||
|
TextBody ->
|
||||||
|
div [ property "innerHTML" (string message.text) ] []
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
attachments : Message -> Html Msg
|
||||||
|
attachments message =
|
||||||
|
let
|
||||||
|
baseUrl =
|
||||||
|
"/serve/m/attach/" ++ message.mailbox ++ "/" ++ message.id ++ "/"
|
||||||
|
in
|
||||||
|
if List.isEmpty message.attachments then
|
||||||
|
div [] []
|
||||||
|
else
|
||||||
|
table [ class "attachments well" ] (List.map (attachmentRow baseUrl) message.attachments)
|
||||||
|
|
||||||
|
|
||||||
|
attachmentRow : String -> Message.Attachment -> Html Msg
|
||||||
|
attachmentRow baseUrl attach =
|
||||||
|
let
|
||||||
|
url =
|
||||||
|
baseUrl ++ attach.id ++ "/" ++ attach.fileName
|
||||||
|
in
|
||||||
|
tr []
|
||||||
|
[ td []
|
||||||
|
[ a [ href url, target "_blank" ] [ text attach.fileName ]
|
||||||
|
, text (" (" ++ attach.contentType ++ ") ")
|
||||||
|
]
|
||||||
|
, td [] [ a [ href url, downloadAs attach.fileName, class "button" ] [ text "Download" ] ]
|
||||||
|
]
|
||||||
|
|||||||
@@ -10,30 +10,34 @@ import Route
|
|||||||
import WebSocket
|
import WebSocket
|
||||||
|
|
||||||
|
|
||||||
-- MODEL --
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
type alias Model =
|
type alias Model =
|
||||||
{ messages : List MessageHeader }
|
{ wsUrl : String
|
||||||
|
, messages : List MessageHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
init : Model
|
init : String -> Model
|
||||||
init =
|
init host =
|
||||||
{ messages = [] }
|
{ wsUrl = "ws://" ++ host ++ "/api/v1/monitor/messages"
|
||||||
|
, messages = []
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- SUBSCRIPTIONS --
|
-- SUBSCRIPTIONS
|
||||||
|
|
||||||
|
|
||||||
subscriptions : Model -> Sub Msg
|
subscriptions : Model -> Sub Msg
|
||||||
subscriptions model =
|
subscriptions model =
|
||||||
WebSocket.listen "ws://192.168.1.10:3000/api/v1/monitor/messages"
|
WebSocket.listen model.wsUrl
|
||||||
(decodeString MessageHeader.decoder >> NewMessage)
|
(decodeString MessageHeader.decoder >> NewMessage)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- UPDATE --
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
type Msg
|
type Msg
|
||||||
@@ -58,7 +62,7 @@ update session msg model =
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- VIEW --
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
view : Session -> Model -> Html Msg
|
view : Session -> Model -> Html Msg
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ time, mark, audio, video {
|
|||||||
vertical-align: baseline;
|
vertical-align: baseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #337ab7;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
}
|
}
|
||||||
@@ -46,6 +51,33 @@ body, input, table {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** SHARED */
|
||||||
|
|
||||||
|
a.button {
|
||||||
|
background-color: #337ab7;
|
||||||
|
background-image: linear-gradient(to bottom, #337ab7 0, #265a88 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fff;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: normal;
|
||||||
|
margin: 4px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
text-shadow: 0 -1px 0 rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.well {
|
||||||
|
background-color: var(--selected-color);
|
||||||
|
background-image: linear-gradient(to bottom, #e8e8e8 0, #f5f5f5 100%);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,.05);
|
||||||
|
padding: 4px 10px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** APP */
|
/** APP */
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
@@ -313,6 +345,46 @@ li.navbar-active span,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-body {
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.tab-bar {
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
margin: 20px 0 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.tab-bar a {
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
margin-right: 2px;
|
||||||
|
padding: 8px 15px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.tab-bar a.active {
|
||||||
|
color: var(--low-color);
|
||||||
|
border-color: var(--border-color) var(--border-color) var(--bg-color) var(--border-color);
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.tab-bar a:focus,
|
||||||
|
nav.tab-bar a:hover {
|
||||||
|
background-color: var(--selected-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.tab-bar a.active:focus,
|
||||||
|
nav.tab-bar a.active:hover {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachments {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
/** STATUS */
|
/** STATUS */
|
||||||
|
|
||||||
.metric-panel {
|
.metric-panel {
|
||||||
|
|||||||
Reference in New Issue
Block a user