diff --git a/cmd/inbucket/main.go b/cmd/inbucket/main.go index ba37c09..b65c9ad 100644 --- a/cmd/inbucket/main.go +++ b/cmd/inbucket/main.go @@ -124,9 +124,9 @@ func main() { retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan) retentionScanner.Start() // Start HTTP server. - web.Initialize(conf, shutdownChan, mmanager, msgHub) + webui.SetupRoutes(web.Router.PathPrefix("/serve/").Subrouter()) rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter()) - webui.SetupRoutes(web.Router) + web.Initialize(conf, shutdownChan, mmanager, msgHub) go web.Start(rootCtx) // Start POP3 server. pop3Server := pop3.New(conf.POP3, shutdownChan, store) diff --git a/etc/dev-start.sh b/etc/dev-start.sh index a35db19..995edde 100755 --- a/etc/dev-start.sh +++ b/etc/dev-start.sh @@ -6,13 +6,21 @@ export INBUCKET_LOGLEVEL="debug" export INBUCKET_SMTP_DISCARDDOMAINS="bitbucket.local" export INBUCKET_WEB_TEMPLATECACHE="false" export INBUCKET_WEB_COOKIEAUTHKEY="not-secret" +export INBUCKET_WEB_UIDIR="ui/build" export INBUCKET_STORAGE_TYPE="file" export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket" -export INBUCKET_STORAGE_RETENTIONPERIOD="15m" +export INBUCKET_STORAGE_RETENTIONPERIOD="3h" if ! test -x ./inbucket; then 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 fi diff --git a/pkg/rest/testutils_test.go b/pkg/rest/testutils_test.go index 520532c..0df7eec 100644 --- a/pkg/rest/testutils_test.go +++ b/pkg/rest/testutils_test.go @@ -49,8 +49,8 @@ func setupWebServer(mm message.Manager) *bytes.Buffer { }, } shutdownChan := make(chan bool) - web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{}) SetupRoutes(web.Router.PathPrefix("/api/").Subrouter()) + web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{}) return buf } diff --git a/pkg/server/web/helpers.go b/pkg/server/web/helpers.go index 838950f..50dcc61 100644 --- a/pkg/server/web/helpers.go +++ b/pkg/server/web/helpers.go @@ -54,11 +54,11 @@ func Reverse(name string, things ...interface{}) string { // TextToHTML takes plain text, escapes it and tries to pretty it up for // HTML display -func TextToHTML(text string) template.HTML { +func TextToHTML(text string) string { text = html.EscapeString(text) text = urlRE.ReplaceAllStringFunc(text, WrapURL) replacer := strings.NewReplacer("\r\n", "
\n", "\r", "
\n", "\n", "
\n") - return template.HTML(replacer.Replace(text)) + return replacer.Replace(text) } // WrapURL wraps a tag around the provided URL diff --git a/pkg/server/web/helpers_test.go b/pkg/server/web/helpers_test.go index 39dc6eb..9cc2968 100644 --- a/pkg/server/web/helpers_test.go +++ b/pkg/server/web/helpers_test.go @@ -1,30 +1,55 @@ package web import ( - "html/template" "testing" - - "github.com/stretchr/testify/assert" ) func TestTextToHtml(t *testing.T) { - // Identity - assert.Equal(t, TextToHTML("html"), template.HTML("html")) - - // Check it escapes - assert.Equal(t, TextToHTML(""), template.HTML("<html>")) - - // Check for linebreaks - assert.Equal(t, TextToHTML("line\nbreak"), template.HTML("line
\nbreak")) - assert.Equal(t, TextToHTML("line\r\nbreak"), template.HTML("line
\nbreak")) - assert.Equal(t, TextToHTML("line\rbreak"), template.HTML("line
\nbreak")) -} - -func TestURLDetection(t *testing.T) { - assert.Equal(t, - TextToHTML("http://google.com/"), - template.HTML("
http://google.com/")) - assert.Equal(t, - TextToHTML("http://a.com/?q=a&n=v"), - template.HTML("http://a.com/?q=a&n=v")) + testCases := []struct { + input, want string + }{ + { + input: "html", + want: "html", + }, + // Check it escapes. + { + input: "", + want: "<html>", + }, + // Check for linebreaks. + { + input: "line\nbreak", + want: "line
\nbreak", + }, + { + input: "line\r\nbreak", + want: "line
\nbreak", + }, + { + input: "line\rbreak", + want: "line
\nbreak", + }, + // Check URL detection. + { + input: "http://google.com/", + want: "http://google.com/", + }, + { + input: "http://a.com/?q=a&n=v", + want: "http://a.com/?q=a&n=v", + }, + { + input: "(http://a.com/?q=a&n=v)", + want: "(http://a.com/?q=a&n=v)", + }, + } + 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) + } + }) + } } diff --git a/pkg/server/web/server.go b/pkg/server/web/server.go index bc7bc5e..34aa996 100644 --- a/pkg/server/web/server.go +++ b/pkg/server/web/server.go @@ -7,7 +7,6 @@ import ( "net" "net/http" "net/http/pprof" - "path/filepath" "time" "github.com/gorilla/mux" @@ -66,11 +65,8 @@ func Initialize( manager = mm // Content Paths - staticPath := filepath.Join(conf.Web.UIDir, staticDir) log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir). Msg("Web UI content mapped") - Router.PathPrefix("/public/").Handler(http.StripPrefix("/public/", - http.FileServer(http.Dir(staticPath)))) Router.Handle("/debug/vars", expvar.Handler()) if conf.Web.PProf { Router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) @@ -81,6 +77,8 @@ func Initialize( log.Warn().Str("module", "web").Str("phase", "startup"). 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 if conf.Web.CookieAuthKey == "" { diff --git a/pkg/webui/mailbox_controller.go b/pkg/webui/mailbox_controller.go index 71dbd42..37013aa 100644 --- a/pkg/webui/mailbox_controller.go +++ b/pkg/webui/mailbox_controller.go @@ -6,139 +6,88 @@ import ( "io" "net/http" "strconv" + "time" "github.com/jhillyerd/inbucket/pkg/server/web" "github.com/jhillyerd/inbucket/pkg/storage" + "github.com/jhillyerd/inbucket/pkg/stringutil" "github.com/jhillyerd/inbucket/pkg/webui/sanitize" "github.com/rs/zerolog/log" ) -// MailboxIndex renders the index page for a particular mailbox -func MailboxIndex(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) { - // Form values must be validated manually - name := req.FormValue("name") - selected := req.FormValue("id") - if len(name) == 0 { - ctx.Session.AddFlash("Account name is required", "errors") - _ = ctx.Session.Save(req, w) - http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther) - return nil - } - name, err = ctx.Manager.MailboxForAddress(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 - } - // 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, - }) +// JSONMessage formats message data for the UI. +type JSONMessage struct { + Mailbox string `json:"mailbox"` + ID string `json:"id"` + From string `json:"from"` + To []string `json:"to"` + Subject string `json:"subject"` + Date time.Time `json:"date"` + Size int64 `json:"size"` + Seen bool `json:"seen"` + Header map[string][]string `json:"header"` + Text string `json:"text"` + HTML string `json:"html"` + Attachments []*JSONAttachment `json:"attachments"` } -// MailboxIndexFriendly handles pretty links to a particular mailbox. Renders a redirect -func MailboxIndexFriendly(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) { - 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", web.Reverse("MailboxIndex"), name) - http.Redirect(w, req, uri, http.StatusSeeOther) - return nil +// JSONAttachment formats attachment data for the UI. +type JSONAttachment struct { + ID string `json:"id"` + FileName string `json:"filename"` + ContentType string `json:"content-type"` } -// MailboxLink handles pretty links to a particular message. Renders a redirect -func MailboxLink(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 +// MailboxMessage outputs a particular message as JSON for the UI. +func MailboxMessage(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) { id := ctx.Vars["id"] name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"]) if err != nil { return err } 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) return nil } - if err != nil { - // This doesn't indicate empty, likely an IO error - return fmt.Errorf("GetMessage(%q) failed: %v", id, err) + attachParts := msg.Attachments() + attachments := make([]*JSONAttachment, len(attachParts)) + 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())) - htmlAvailable := msg.HTML() != "" - var htmlBody template.HTML - if htmlAvailable { + // Sanitize HTML body. + htmlBody := "" + if msg.HTML() != "" { if str, err := sanitize.HTML(msg.HTML()); err == nil { - htmlBody = template.HTML(str) + htmlBody = str } else { - // Soft failure, render empty tab. + htmlBody = "Inbucket HTML sanitizer failed." log.Warn().Str("module", "webui").Str("mailbox", name).Str("id", id).Err(err). Msg("HTML sanitizer failed") } } - // Render partial template - return web.RenderPartial("mailbox/_show.html", w, map[string]interface{}{ - "ctx": ctx, - "name": name, - "message": msg, - "body": body, - "htmlAvailable": htmlAvailable, - "htmlBody": htmlBody, - "mimeErrors": msg.MIMEErrors(), - "attachments": msg.Attachments(), - }) + return web.RenderJSON(w, + &JSONMessage{ + Mailbox: name, + ID: msg.ID, + From: msg.From.String(), + To: stringutil.StringAddressList(msg.To), + Subject: msg.Subject, + Date: msg.Date, + Size: msg.Size, + 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 @@ -191,48 +140,6 @@ func MailboxSource(w http.ResponseWriter, req *http.Request, ctx *web.Context) ( 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 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 diff --git a/pkg/webui/routes.go b/pkg/webui/routes.go index d6da620..537abca 100644 --- a/pkg/webui/routes.go +++ b/pkg/webui/routes.go @@ -6,7 +6,7 @@ import ( "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) { r.Path("/").Handler( web.Handler(RootIndex)).Name("RootIndex").Methods("GET") @@ -16,22 +16,12 @@ func SetupRoutes(r *mux.Router) { web.Handler(RootMonitorMailbox)).Name("RootMonitorMailbox").Methods("GET") r.Path("/status").Handler( web.Handler(RootStatus)).Name("RootStatus").Methods("GET") - r.Path("/link/{name}/{id}").Handler( - web.Handler(MailboxLink)).Name("MailboxLink").Methods("GET") - r.Path("/mailbox").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( + r.Path("/m/{name}/{id}").Handler( + web.Handler(MailboxMessage)).Name("MailboxMessage").Methods("GET") + r.Path("/m/{name}/{id}/html").Handler( 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") - r.Path("/mailbox/dattach/{name}/{id}/{num}/{file}").Handler( - web.Handler(MailboxDownloadAttach)).Name("MailboxDownloadAttach").Methods("GET") - r.Path("/mailbox/vattach/{name}/{id}/{num}/{file}").Handler( + r.Path("/m/attach/{name}/{id}/{num}/{file}").Handler( web.Handler(MailboxViewAttach)).Name("MailboxViewAttach").Methods("GET") - r.Path("/{name}").Handler( - web.Handler(MailboxIndexFriendly)).Name("MailboxListFriendly").Methods("GET") } diff --git a/ui/elm-package.json b/ui/elm-package.json index 7901d01..95bd2ee 100644 --- a/ui/elm-package.json +++ b/ui/elm-package.json @@ -14,6 +14,9 @@ }, "/debug": { "target": "http://localhost:9000" + }, + "/serve": { + "target": "http://localhost:9000" } }, "dependencies": { diff --git a/ui/public/favicon.ico b/ui/public/favicon.ico deleted file mode 100644 index d7057bd..0000000 Binary files a/ui/public/favicon.ico and /dev/null differ diff --git a/ui/static/favicon.png b/ui/public/favicon.png similarity index 100% rename from ui/static/favicon.png rename to ui/public/favicon.png diff --git a/ui/public/index.html b/ui/public/index.html index 40cb71b..feb4250 100644 --- a/ui/public/index.html +++ b/ui/public/index.html @@ -10,8 +10,8 @@ homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/ --> - - Elm App + + Inbucket