From dd14fb99897943a60656e5ee1bcc98f9d838409d Mon Sep 17 00:00:00 2001 From: James Hillyerd Date: Sat, 2 Jun 2018 12:53:24 -0700 Subject: [PATCH] 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, --- cmd/inbucket/main.go | 4 +- etc/dev-start.sh | 12 +- pkg/rest/testutils_test.go | 2 +- pkg/server/web/helpers.go | 4 +- pkg/server/web/helpers_test.go | 69 ++++++---- pkg/server/web/server.go | 6 +- pkg/webui/mailbox_controller.go | 203 ++++++++--------------------- pkg/webui/routes.go | 22 +--- ui/elm-package.json | 3 + ui/public/favicon.ico | Bin 103614 -> 0 bytes ui/{static => public}/favicon.png | Bin ui/public/index.html | 4 +- ui/src/Data/Message.elm | 26 ++-- ui/src/Data/Session.elm | 10 +- ui/src/Main.elm | 4 +- ui/src/Page/Mailbox.elm | 207 +++++++++++++++++++++--------- ui/src/Page/Monitor.elm | 22 ++-- ui/src/main.css | 72 +++++++++++ 18 files changed, 384 insertions(+), 286 deletions(-) delete mode 100644 ui/public/favicon.ico rename ui/{static => public}/favicon.png (100%) 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 d7057bd07e970a1212f6a8a048c2343026118353..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 103614 zcmeHQ30M;tfk1xTnave!he z_Qx;?XGl?f(^v%YK>~wVHG1UG=1p5Sg{qqm3l12AuA+^NOoz|g1vZ^rh6N0IfAN;m zo5O`;a~atcw_7D0{BPR+wAI~beYAQ0bNy}YQ@OVn-PDz29$}O`HZ|VQ}}V>keOMj>vPVXicDT|s(WME zyP1ytpZ$Gg(YR|XuOEG-@1&>yX3M01lpHLRK6p2A@{XaY>(3YDcX+FOey_!OK155- zIVR`aRURWDj`v0!Z(-vWf&kX{Jl6MPx=`1G?hwHN+-C6KkZ$?2j;EG<`)qamoStug z79Gxx{VwcO`M;xDh<;dbp{i^4-#v~!h}#?5ZhGOb7mF?@toi5Jx#J_8J{S(k-q;Jed31)-eSv92BS%0m}o6+7t_PFx`um=Nck zW2ZH5O`RH(NM)a&)|l7TGwS&e|J|P^upV*Qxk8~nj*;_2+|f-Jn_92<44DFXC4d&SQm5gj}QEd#jy z_d6g!T;{|I4zkYTuvZ=+Ym4K#MmOfoaAnL)zmT2c+}QK-fZL3O)&czD@-yC^3ErNU z_j!|KxXhc=yU9&m7I{%2oQHD|ITqJC#eipsn0!dAAjEqXPeLDg0Gz zq$l7nnlMd{pK)aHD{}ltU!5VxFA*hBRbBkTU0&6+PG3^^mmXyC`ntp>&h!~D`WcrK z?7`Xf=1wAbZO^R!!&zQ!JV%fJ7zhRNg2ewGeeFjcD{WOzV$5>($4BQ*>c8_l#f7wc zLE@2bB_2F5EUf7FutBQ^pxTH}e(uOTc&gF;kQb$d*M{(0Es~u9sARdDXTm!`f^Zrm z=DmXm_7C=B?rsF)m2-31$*yFk*V-o_rjThh@4P%y7iO1e&&%8yG2dT)@#1jQ>DR;l zDjril^A_{vN58-QL)pWB-m8rKHk`@r6DQgIQ(>3s|6AGbw95{M{HpOYFK(O?ed^u6 zIKeO9;vFcQdo-fN=G#j=0;tzGp549v<=##% z(phsqhzQ&`4zMKbZ(Qt?b+X;`*uCp}oRoe&aG4|~7_P!KPu)1v{>v)vamJ0o z^z!w*ygn`~BCZdUmDJj^Xxs`o@5*3~Zp$g}_y-hOO)z$)hO+i9{PgI1H(I*JwA;yc z7W`W>l%KxPIo7?2DARd3i_Pm5khGhX9TC?KYVq?NJ@&`q9!ZW1AkR0Eo|4L&Gr@4uxGO^i6)%1b0-n#ei-_Xqzbt}X*X^qXYbNlk zO3NX;bNn<dNlEpcD^~`UV8xKQU<*Ly9eUI*<%ju4%_t_ z?7n#q?Y8n|vbYm2fj7SHJsNo5Xbs+YbKe};t!f=uR1$N4VAi(7S0O5T{}#{N>Yw7% z?-NiUdB;Ll>5x6_WMI*a$_STY@%qH|6Ce;0-0yMjx01Pp4|wzV zT$E$;mOw#8YN`)d94~5L{#(D>|7`7fH>9WZ&kv_Rc(CVgS$kr7uU@^52TsjA7Q7(u zR@VnjH@FP;x>V4r*GQt}l;j~(2TJps?k>Fj#3ips-&42F{WUlGt@)Rhb=uSjX)sNBXPoHJS%u7jpxnu9metif2!_Up{$o%%uwu>L$oZ!7|6%nxSTIIz- z!=79kDB9YI*~PD)bX)Unu4T`Ina*8?7MCqK{x2sX{luQF%X=&rM|cwdo!4KwHFcj$ zZ1bPq%xD?w{C*q2w7qzG)qqi>Sbw~~6yU~vb+6+2Nw}pB|Dob~&*mIK@ZsLltsB}i z=kD^89_cOnt@)>45NCepuP*N499z`KRrXom$mBaLZnR^n|EG&aTtKJ!xgN5WWe<|p ztRG(0ig*9G8_;B>UaQ=9czfjlrt^MhRWr4pgujnsja;A-6UNP$7LxRuOKgPZ;<=gTTPGMztn&5nt^QXC+0l%dl3e<1(+ z=3Rnoms6mV;4C&`_L~wpmx9Orx9;@{XFKL^@qdr&^vNVu+`Bd+0$k(Tj zTW0UDdm&ADC$753d^?C0`E-}K+ZX%(hl-6@BFr4!i`i!OmV&KgS;vu&3)kjXHVa4+ z&qlg?|LF%jHV%0XAOv%kh`~m8P8NVDdbPMLkaelaRM4UPi;7>e?pIu?`1xXpIOWyY z@4y3uhilBS?8idhVpMXbFve?BAU6k0N;+mmj3{M)I6YO|?4K*ss(PF+M%6VfK9KqW z%e(vL0?trYYjKPyb@ewxiOrL%E_P@gTaKXYbj@tDDv%qGU=Q4}p;hL4s{*)N(UJR( z4XOUoi{2_;)crC0OdpWy{p`x6t9vsmke744q#0-4h@Cz;{p}3M&5sgh&N>*tFFe2Z zL;vVMoI+ZB;RPjL`LbeB$EWOf$8CDOjZ0hqF?U9HMt+YGX3k7+&)qaMI{f~Y`v}Io zKG`w(2X}`|s_LHm;_&Z*+1dRAlcbfIX}7y3pz3Eg&ziG$05_{FtTSht_p&|dEy}LC zGB!3Zb_&Vo1kV6>9d#>q8b6j3JR9x17K;O~bAsogeL+R`&VB3-{Q%CEvz3$Y^kWV~ zEkWqN?38ChRb|Qz2TA37zxQQ^3M0<@!RbExJrgcgri7!@xxJa8qKNZ7;q-#^_Flbv zee%i3;tlhf5yig*?qgRyefXv<{7%I|am4v9ki|#5uhq8bbNOGVdM)d+f$)<4Cv>`4 z%uarLS(lY?wD(v%@15ceW1NWMl{cUp^LbJ^qAMJ?Ac}{IcXE9FmreiXrOPwA32S?E05SYn z5cfQss7#p5+TM(Jv*LhX-}KEDJGR~MbPgdl3vahR5h3(&IM;=Fg#{2sjPT@6->|aV z5>6oD>VN!l(XU?~>9Us1@*uLr-Ba9SlfYw~&)2ng{unL~UOR*J`72;GZj6u@#o$-5 zLuYWx@O~DR`$@Zi zq(hLU!`Z@{J%f_IM!Q{pWFJPkll!ce>~Np`ep)V^jr?{QD`U?c_Te|+%HDvMu^%v@ zo)orc(EuuwG+KMOhlf z21tehkt7Jvq3vV0*sH*fOSrf9nhKVjokwvt_5tARgq7^@Hw4Fv zJ=*&JGbGDt@s!d>C*rr>81t+koO84@F(RwSrVVE*8D+wrC&B}ZqET6=u^iX6khXW> zxK66#o!lJ6zyCxmU9t_%?d!zx3UTVWjQeh0;XcRp(K8n%_DTd7#(ozfl1Pw0+|mO0 zNeD~#aJHTsh=uz(*1t9R=D?BfB88xQEEy$1bdiz7q-w8wzOXENW+aIdnjB{O_3(PY{i)c)~3LCw9u9s>r? z9hp>nqv&t%2TXM@FuC?d)88Hg#*P8AXm3>g?J;1i7|^8sEu@>VHiO20cnm;K?c)JM z;=ruh8@>JGIIzcnJ~3c+?M=@_FQVV0O}TH<`+vCtq~kJ1>569qsW66UIP2>fcaeK$G@2 zL4O%+FY6-)>REqVj{$A9NBY-G3|N5vwi*K}weRz*s4eJ?+dbAEr)KR3EJS}>ivhLT zd*_L6Q0*OSUOP2g3|NrVEHPkV`dfPp7)$%=81N|&O~Q`>3aL!SAdK)_ zV1fFRF|cq__?rrRsRIjsZ)j}o%@P9^s=u|xfT^@Mc??*v{?-x$rqWfEDO(sWD(~?X`=6l(Gc;Zcm%kO7yq17^pApjUEG5q`#%aKz(a()EKZb{p&jh zEJb^x#efy+UtckxQhT54tK77h)5V$@JAJ8kj!46Jz)JNucMPc29_I3|YnabBw&u0# zgBXaPsBFHBK=XuZV!(>^M=@~M4D*DRu04(g^)ZnAXrr>{0V~&En;5uez~3^m7VY(p z0UOZYR59@Tg~O9PmG!CUes2MNKGwW;s#8B;Bl;UV2CP+k91B{b&UaA)m~!^*s%U&3@n)v zivARCogcN12t6CB)V@#dSFS#JqP%*Z+rb(dn!Z%l57@Z=>SG{d;_H_WO=wdmIN*eh zX^&&4P7E{v{nf@m1JYh;3^Wk^wT=NBZTDDvoL2V(4M=|!1Al!Q8QNW0n@8vR<_SIE zUVqC*?)O-0RjJfFUo^FW>0kKS1m*X8q`%Lv2`-?0ZUfO@2JfubHz57hYLE0sKfi17 zZG7_M3mZTTY$Id>W&_b*EA8bG&>&)f)E@C|0Q#%eKBb}~k^JZ_tUEom;eMc6`v#!D zYVB+OZpIyA*JGvMsIW2OK0-1^1?sy~Y^mSFoxz=x;$Wpqutq zuD?07x1Jct6MduX`5ryv;jtdlyY^r;En ziva_*w=(@LL3_)I0V8N{MfzKk_VphFM$+C&^tVLq>n{e3sJ#{FZ^_!5KL(7fy@l&< z4ceP8227y61?z83+M7NGOr*Vq>TiwOYZ(K=0-fI%m{5BQ)Zd!5H+~G5SbGc8-xjnt zUJRH)dkfOvmb5oK2F#?rh3IdK+Up$yz6GK!`n<F;ZosfYewwf2a6+LPCblyXm-+;%>7 zsU@%4LsM&C5A9#Q_G-P+ur8WIdo$~A*IvsIHkbBh)ZebX1!`|5{q5RYsP<;iU#0ea z3dNrAF61I?=#Fn zQWNQK*FIlV6HgYZJ&F$#=x^8FO0+kD{&wxHNP8peZ`a<+v^S#ucI~ZDdn4&@*WOCC zH-i3l?X6gQ1NFCSZ{^w>pub&v8_-@){q5S@i1vEuZ`a<2wAWF8yY@Dwy$<@@wWn!c zUu!jyUdG34(f+Ti`g=Kj8sIxzTWa^XA5qi(uP=-o=ca7Ef?az>uhiGt z4XmLdsS5q=+S_oSPmdQR`rEZ{AljoisHMMM`v#^9qs;Ug=N>M`t}0&XBuo$$`CR{#JP=>Sk+IsjBT9RM;;2Y{~8 z0id#U0A$@X0Hkdi0P<1|0C~|G0P?D90LV?(0FYm)0U$3~1AwZz8USUv8US?x1Hc6_ z00+vI7=R;ni~%@NeuByugDa*0NO8q-0E{YJu{@tcC9YVW&#@d= zEYAmL6#zgfs$zLQs1-9K^T9<00C18H02QJGK*s3+&^0;$vMdb%SvL&;X`2RsyhH;) z7Oepwuet_++;j~9`IQ;~RLN=pRL#`@D9iqG1qdm1!Gk)Ep$a-t$NpGb>ez#7Ru0s$ z6V>b(wT`Jn0_VrqaecTvw&zF{z<4P8j4BE*h0mdq;zQwgET{NVG@ObkPf;{HByvw- z|M&~!d)R+ajLeDs3kPIPRIMm834pSSijlh%DpZWjT5X<+m1^_ySgA2Dj}@AE5&+FS z34msv1VA%S0-%{E0np5o0BGhB0J?buK*2l!P%sYw6wCvFZ4y!)T+BlzAdeko_(vWR zPWTX;$H!C*`Qu|MhGOtB6+=`p!lQ_+52MDd6Pw4a7o*0ln?g+~TGOa;J(Sa`8!zRw z>c)-D*J9r=%@dSnm_0#hhMiAPnqlV?ok24mYy#LA1Q-6V`j?r>XTvz?n5~Y5YV*2~ z&Oe=-IpY=Nk@@4tH$!Pd@d2GXkyhMf@#JxbXU!T$_V>2#14Zt##>CFDgYx%ew(kRF z+=-Mj+IvFV^+6r(DP`Xy?uz`wz!$dX19jZ%^0zG?XyUG%zis$H8}6$4TelCi<*uH; zb^1UF_d|%Ms;$;K5!(F#@wYA?=*nHQ{?_9IeYk7S-?HOCU+%i`w^$$ih@WMM44ETv zQrq4UXZohO{o^)()0#0-R$54tdVX#+h`a9kTa*uKxT6Qyb8(v+7&pe^?}j&o6ZM{-19`=X?Pbe)x`%!WBcfuY>B_JPUx>+b_&aaZnxou%ENPkeyy3nt~SyAO=bU84_7&R=^U zn2NhvADD%|W*?ZEd$kX|^F>pu#|J4D9nHpHwGR}z=Zg+%xW8jA`leF456sHHmJf2% zLK>rYjz|ymECakv%^mx!+6QLmk9;sWG!wsfM%?|1W8_bby23|uOoNU`9KACQ}Wl7yHX$cuNZv?-SMdKp*ag@;5Vg?3+4qpf7hr_$zQnL77q>LZm#}WR`8`qflg${L$Wj z)5mqn+PboH`n3N#+%cAVO!eWekN&pAU042e?r6@6ny+{SV^gzk-UaasaH#34J8lcw z#y_1qnyd8A7k;nznB0(mSFOX?26I%GYQx`FxoZ>uw#;1_VxAy1>I4}%mr2+#?JgdssG92KF| zks!zlAU9b7lqM^H1F{0R2w4b}*+BrsA!SHlAAP+5_8k{P3VB-ehZM@VxkCyS_@d8` zKm}2b0(DhXex2cbvx!Y)w+C*cxl7iASNBt(%M7wnNZ9RzaRj*uO)x{<}< z5?LIwqT;9sz;-}i-9Y;&mB=k2nkXw(Ty7nXsp4|$a767>k0GWrPMq_#ga*(eiqK54t6x3Pn##PWDd`A4o?__{7X=N$L}I&_z@RN z?-gp=x{z`qJKU0ts1eb!C&7Tw8E|de|>S#8oL(w&CNk4>{{X{ zIV}3{?@325jZHpE+W@a!@oP7rws=^ z@FQN2aV%XrG{FyasL*5FtP(qZ-qnVKp7<3wT+HD6>d2uQyI;PjuRLbte#h8x3TxTt zs?)&${Q7dx8oM?e48*T12c5BN;$Q^)+H%kvJLce%CxRzy_|f`SBjG1GESWs6AF5tS zdoUb3ez#nUgAws7<4}(75x9S++G<6$KQSg>WH1W_%sDAYG&0oy}>`x8$do7)O^F)hGhJR9+{CO4d z!@7>%@J)v@W8?QO5XG4Q|Fcgq_8pG~5If3vNCUt8cz>g6kQbj|f`6w&!(RS2j2%m1 zs~~okl5;agjH8VRd(Md|PDb>0{$B8wdTdtJU3OqH_s9Qpq`EEjVnZ$2-DI-}Hui=2&J^W}Kw8E|<{u=CQPhN-ixevAzM%oS#JD<8B4tV|q_wV#rSGKQZ{40#d zkR4P0w8gHa|Esb4Tod+3V?I-C{R&pKMn2I6yB7GXvDeHkDW50M{a&h2&uV?JYrt>u z*j3_>4Eq~De^{#D5q72c0XuxNECA_gTI@ohzA=Nb*THXP*cI^C19tdZZxXsM_1P!; ze;Rg8^O1)2Gc_?|4D6);tr$DPkJoQlo8NJ)p+0~%ZmRwUi(zq6b8mtLI_x0`pab?dY?O_|aN^xtYM-L$P}>P zS$93^r_G6hZX3-dR6i#~hHWslAF6F9mnS0I3ii?aX%k{lX1k7k6JbzgTd92`W2m;B zQnn8JoSs(ekL=PnBWQtar=^8BfW5hNoAiAJn{@jI`hnz)zR^|?KZ^L~VRNu~hHe+{ ztF^Bu2JCZqmy(F?m*l%%6&N(y*AYW)+xUL13WHYmwZx#xHpW1{r$m0BtzpsHz6J~$ zZR2-D=p7vmgHHBo7~np$4c}|Dwygw%&h~3zK(;rb@6l1QR_$wSOL;1P&j;Ug)5|_! zfPaUQA`tLc0-1mXZEU-ib`{2v2EM1;)@xHvBO*rz-{KRk)5pF6-_zR`l9mSjTKNX} zn`}w&$M{(A4ePBsVSs-ZosluiQ4jyu*v9L?bj1L+(f9-6H$_ML*!Ftde^#f-kKo-| zngjS?n_556Y@_z0gMHPuu^%*HsJ0!k6SrM0?Q6D;F`$?LKltVglN9`*#I~0Fb+C=s z1(t8%$;g;YB{6_)bRW)1ui>pF|7`*R{Jn~lvN^Ej=(O!Dn}^=dzw! z^i+-i<+kDZ!H^%aos%(fKiwvMUxoc`JZK-_yUnoa^{oi>%?C}k5e_BxNxk9u_-lpu zQQnU$^rtvK`o4@dww2f?Z9_eJ_NOA5F1BmgH`q3O-$O+5PUjz>Ypiac^1s}+Zuihy zz84C0wN3Fiif#05VyI7R-qifN2bZ$K(@SmKWj7b|u)(uMjM5V}i+ZfSCyfIXl6GtSl(P zhN8|;1sAH~L6s5*2}6cHIOIUE2)GRYcZUCS;r~4NzW}O7|DFu&!jbxUik)l6j{sf| zbm2zjeK7yKZ6hGgic{A{4A-%a2aEP?84mq!F&U?kp?~U7M+sSWXW5@nzNVk4siQ;< zmy+cN)&@bj>c2&z&`&`fY3oTY%IYW)otZb%r(kUqlutvp73`z&D>;S@{d3Q{3GV_1 zdL1Qe&qfW0aal2nL+|`G^I&|nM`?L^J$GnzP-R7b(qdTZoeOoqJ6Q$%7=t`5*|92o z8PWFi4?!4r?K=FfiM~`?9?Fi0CjERHFcy1A zGKSU0;CC%P{=#9txK4S+viP0%?RKXR0S-A2-)qn(rSlVz&b7*yPoUyKUS#!uEEf6#KI - - Elm App + + Inbucket