From 1260cfd18fa341973910e9badc5b435a41c042f8 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Tue, 7 Apr 2026 10:06:18 +0200 Subject: [PATCH] feat: add PWA support with Android share intent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make Favoritter installable as a Progressive Web App with offline static asset caching and Web Share Target API for Android. New files: - internal/handler/pwa.go: handlers for manifest, service worker, and share target - web/static/sw.js: service worker (cache-first static, network-first HTML) with {{BASE_PATH}} placeholder for subpath deployments - web/static/icons/: placeholder PWA icons (192, 512, 512-maskable) Key design decisions: - Share target uses GET (not POST) to avoid CSRF token issues — Android apps cannot provide CSRF tokens - Manifest is generated dynamically to inject BasePath into start_url, scope, icon paths, and share_target action - Service worker served at /sw.js with Cache-Control: no-cache and BasePath injected via string replacement - handleShare extracts URLs from Android's "text" field as fallback (many apps put the URL there instead of "url") - handleFaveNew replaced with handleFaveNewPreFill that reads url, description, notes from query params (enables share + bookmarklets) - SW registration in app.js reads base-path from tag (CSP-safe) Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/handler/fave.go | 13 --- internal/handler/handler.go | 7 +- internal/handler/pwa.go | 142 +++++++++++++++++++++++ internal/handler/web_test.go | 149 +++++++++++++++++++++++++ web/static/icons/icon-192.png | Bin 0 -> 1137 bytes web/static/icons/icon-512-maskable.png | Bin 0 -> 3386 bytes web/static/icons/icon-512.png | Bin 0 -> 3386 bytes web/static/js/app.js | 7 ++ web/static/sw.js | 66 +++++++++++ web/templates/layouts/base.html | 5 + 10 files changed, 375 insertions(+), 14 deletions(-) create mode 100644 internal/handler/pwa.go create mode 100644 web/static/icons/icon-192.png create mode 100644 web/static/icons/icon-512-maskable.png create mode 100644 web/static/icons/icon-512.png create mode 100644 web/static/sw.js diff --git a/internal/handler/fave.go b/internal/handler/fave.go index 9a5dd82..fb75784 100644 --- a/internal/handler/fave.go +++ b/internal/handler/fave.go @@ -48,19 +48,6 @@ func (h *Handler) handleFaveList(w http.ResponseWriter, r *http.Request) { }) } -// handleFaveNew shows the form for creating a new fave. -func (h *Handler) handleFaveNew(w http.ResponseWriter, r *http.Request) { - user := middleware.UserFromContext(r.Context()) - - h.deps.Renderer.Page(w, r, "fave_form", render.PageData{ - Title: "Ny favoritt", - Data: map[string]any{ - "IsNew": true, - "DefaultPrivacy": user.DefaultFavePrivacy, - }, - }) -} - // handleFaveCreate processes the form for creating a new fave. func (h *Handler) handleFaveCreate(w http.ResponseWriter, r *http.Request) { user := middleware.UserFromContext(r.Context()) diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 2b5f879..c2f3a80 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -74,6 +74,10 @@ func (h *Handler) Routes() *http.ServeMux { // Health check. mux.HandleFunc("GET /health", h.handleHealth) + // PWA: manifest and service worker (public, no auth). + mux.HandleFunc("GET /manifest.json", h.handleManifest) + mux.HandleFunc("GET /sw.js", h.handleServiceWorker) + // Auth routes (rate-limited). mux.Handle("POST /login", h.rateLimiter.Limit(http.HandlerFunc(h.handleLoginPost))) mux.Handle("POST /signup", h.rateLimiter.Limit(http.HandlerFunc(h.handleSignupPost))) @@ -91,8 +95,9 @@ func (h *Handler) Routes() *http.ServeMux { // Faves — authenticated routes use requireLogin wrapper. requireLogin := middleware.RequireLogin(h.deps.Config.BasePath) + mux.Handle("GET /share", requireLogin(http.HandlerFunc(h.handleShare))) mux.Handle("GET /faves", requireLogin(http.HandlerFunc(h.handleFaveList))) - mux.Handle("GET /faves/new", requireLogin(http.HandlerFunc(h.handleFaveNew))) + mux.Handle("GET /faves/new", requireLogin(http.HandlerFunc(h.handleFaveNewPreFill))) mux.Handle("POST /faves", requireLogin(http.HandlerFunc(h.handleFaveCreate))) mux.HandleFunc("GET /faves/{id}", h.handleFaveDetail) mux.Handle("GET /faves/{id}/edit", requireLogin(http.HandlerFunc(h.handleFaveEdit))) diff --git a/internal/handler/pwa.go b/internal/handler/pwa.go new file mode 100644 index 0000000..f64fb20 --- /dev/null +++ b/internal/handler/pwa.go @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package handler + +import ( + "encoding/json" + "io/fs" + "net/http" + "net/url" + "strings" + + "kode.naiv.no/olemd/favoritter/internal/middleware" + "kode.naiv.no/olemd/favoritter/internal/render" + "kode.naiv.no/olemd/favoritter/web" +) + +// handleManifest serves the Web App Manifest with dynamic BasePath injection. +func (h *Handler) handleManifest(w http.ResponseWriter, r *http.Request) { + bp := h.deps.Config.BasePath + + manifest := map[string]any{ + "name": h.deps.Config.SiteName, + "short_name": h.deps.Config.SiteName, + "description": "Lagre og del dine favoritter", + "start_url": bp + "/", + "scope": bp + "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#1095c1", + "icons": []map[string]any{ + {"src": bp + "/static/icons/icon-192.png", "sizes": "192x192", "type": "image/png"}, + {"src": bp + "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png"}, + {"src": bp + "/static/icons/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"}, + }, + "share_target": map[string]any{ + "action": bp + "/share", + "method": "GET", + "params": map[string]string{ + "url": "url", + "text": "text", + "title": "title", + }, + }, + } + + w.Header().Set("Content-Type", "application/manifest+json") + json.NewEncoder(w).Encode(manifest) +} + +// handleServiceWorker serves the service worker JS from root scope. +// BasePath is injected via placeholder replacement so the SW can +// cache the correct static asset paths. +func (h *Handler) handleServiceWorker(w http.ResponseWriter, r *http.Request) { + staticFS, err := fs.Sub(web.StaticFS, "static") + if err != nil { + http.Error(w, "Not found", http.StatusNotFound) + return + } + + data, err := fs.ReadFile(staticFS, "sw.js") + if err != nil { + http.Error(w, "Not found", http.StatusNotFound) + return + } + + content := strings.ReplaceAll(string(data), "{{BASE_PATH}}", h.deps.Config.BasePath) + + w.Header().Set("Content-Type", "application/javascript") + w.Header().Set("Cache-Control", "no-cache") + w.Write([]byte(content)) +} + +// handleShare receives Android share intents via the Web Share Target API +// and redirects to the new-fave form with pre-filled values. +// Uses GET to avoid CSRF issues (Android cannot provide CSRF tokens). +func (h *Handler) handleShare(w http.ResponseWriter, r *http.Request) { + user := middleware.UserFromContext(r.Context()) + if user == nil { + http.Redirect(w, r, h.deps.Config.BasePath+"/login", http.StatusSeeOther) + return + } + + sharedURL := r.URL.Query().Get("url") + sharedTitle := r.URL.Query().Get("title") + sharedText := r.URL.Query().Get("text") + + // Many Android apps send the URL in the "text" field instead of "url". + if sharedURL == "" && sharedText != "" { + sharedURL = extractURL(sharedText) + if sharedURL != "" { + // Remove the URL from text so it's not duplicated. + sharedText = strings.TrimSpace(strings.Replace(sharedText, sharedURL, "", 1)) + } + } + + description := sharedTitle + if description == "" && sharedText != "" { + description = sharedText + sharedText = "" // Don't duplicate into notes. + } + + target := h.deps.Config.BasePath + "/faves/new?" + params := url.Values{} + if sharedURL != "" { + params.Set("url", sharedURL) + } + if description != "" { + params.Set("description", description) + } + if sharedText != "" { + params.Set("notes", sharedText) + } + + http.Redirect(w, r, target+params.Encode(), http.StatusSeeOther) +} + +// handleFaveNewWithPreFill shows the new fave form, optionally pre-filled +// from query parameters (used by share target and bookmarklets). +func (h *Handler) handleFaveNewPreFill(w http.ResponseWriter, r *http.Request) { + user := middleware.UserFromContext(r.Context()) + + h.deps.Renderer.Page(w, r, "fave_form", render.PageData{ + Title: "Ny favoritt", + Data: map[string]any{ + "IsNew": true, + "DefaultPrivacy": user.DefaultFavePrivacy, + "Description": r.URL.Query().Get("description"), + "URL": r.URL.Query().Get("url"), + "Notes": r.URL.Query().Get("notes"), + }, + }) +} + +// extractURL finds the first http:// or https:// URL in a string. +func extractURL(s string) string { + for _, word := range strings.Fields(s) { + if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") { + return word + } + } + return "" +} diff --git a/internal/handler/web_test.go b/internal/handler/web_test.go index 6cda977..aa53c2e 100644 --- a/internal/handler/web_test.go +++ b/internal/handler/web_test.go @@ -1133,6 +1133,155 @@ func TestDisplayNameFallbackToUsername(t *testing.T) { } } +// --- PWA --- + +func TestManifestJSON(t *testing.T) { + _, mux := testServer(t) + + req := httptest.NewRequest("GET", "/manifest.json", nil) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("manifest: got %d, want 200", rr.Code) + } + + ct := rr.Header().Get("Content-Type") + if !strings.Contains(ct, "manifest+json") { + t.Errorf("content-type = %q, want manifest+json", ct) + } + + var manifest map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &manifest); err != nil { + t.Fatalf("parse manifest: %v", err) + } + + if manifest["name"] != "Test" { + t.Errorf("name = %v, want Test", manifest["name"]) + } + + // share_target should exist with GET method. + st, ok := manifest["share_target"].(map[string]any) + if !ok { + t.Fatal("manifest missing share_target") + } + if st["method"] != "GET" { + t.Errorf("share_target method = %v, want GET", st["method"]) + } +} + +func TestServiceWorkerContent(t *testing.T) { + _, mux := testServer(t) + + req := httptest.NewRequest("GET", "/sw.js", nil) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("sw.js: got %d, want 200", rr.Code) + } + + ct := rr.Header().Get("Content-Type") + if !strings.Contains(ct, "javascript") { + t.Errorf("content-type = %q, want javascript", ct) + } + + cc := rr.Header().Get("Cache-Control") + if cc != "no-cache" { + t.Errorf("Cache-Control = %q, want no-cache", cc) + } + + body := rr.Body.String() + if strings.Contains(body, "{{BASE_PATH}}") { + t.Error("sw.js should have BASE_PATH placeholder replaced") + } + if !strings.Contains(body, "CACHE_NAME") { + t.Error("sw.js should contain service worker code") + } +} + +func TestShareRedirectsToFaveNew(t *testing.T) { + h, mux := testServer(t) + cookie := loginUser(t, h, "testuser", "pass123", "user") + + req := httptest.NewRequest("GET", "/share?url=https://example.com&title=Test+Page", nil) + req.AddCookie(cookie) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusSeeOther { + t.Fatalf("share: got %d, want 303", rr.Code) + } + + loc := rr.Header().Get("Location") + if !strings.Contains(loc, "/faves/new") { + t.Errorf("redirect = %q, should point to /faves/new", loc) + } + if !strings.Contains(loc, "url=https") { + t.Errorf("redirect = %q, should contain url param", loc) + } + if !strings.Contains(loc, "description=Test") { + t.Errorf("redirect = %q, should contain description from title", loc) + } +} + +func TestShareTextFieldFallback(t *testing.T) { + h, mux := testServer(t) + cookie := loginUser(t, h, "testuser", "pass123", "user") + + // Some Android apps put the URL in "text" instead of "url". + req := httptest.NewRequest("GET", "/share?text=Check+this+out+https://example.com/article", nil) + req.AddCookie(cookie) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + loc := rr.Header().Get("Location") + if !strings.Contains(loc, "url=https") { + t.Errorf("should extract URL from text field: %q", loc) + } +} + +func TestShareRequiresLogin(t *testing.T) { + _, mux := testServer(t) + + req := httptest.NewRequest("GET", "/share?url=https://example.com", nil) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusSeeOther { + t.Errorf("unauthenticated share: got %d, want 303", rr.Code) + } + loc := rr.Header().Get("Location") + if !strings.Contains(loc, "/login") { + t.Errorf("should redirect to login: %q", loc) + } +} + +func TestFaveNewPreFill(t *testing.T) { + h, mux := testServer(t) + cookie := loginUser(t, h, "testuser", "pass123", "user") + + req := httptest.NewRequest("GET", "/faves/new?url=https://example.com&description=Shared+Page¬es=Great+article", nil) + req.AddCookie(cookie) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("fave new pre-fill: got %d, want 200", rr.Code) + } + + body := rr.Body.String() + if !strings.Contains(body, "https://example.com") { + t.Error("URL should be pre-filled") + } + if !strings.Contains(body, "Shared Page") { + t.Error("description should be pre-filled") + } + if !strings.Contains(body, "Great article") { + t.Error("notes should be pre-filled") + } +} + // --- Export page --- func TestExportPageRendering(t *testing.T) { diff --git a/web/static/icons/icon-192.png b/web/static/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..95fe080e98bd0f9ed0039372081f423513edf292 GIT binary patch literal 1137 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rfyKhp#WAE}&YMebCtgkvU`Z5r zWtbqqIFbJeGv`Dm`Nwr5gW+3(H-`(LlO$Jgr}2#Y@_$8eK_Lx`!Vr9nYa z!DB#}b|2qA@89gc!Og*Y-Pa2jn3!gj-sj~#tGFQZ{Lu>+RG4NNT<7AoRbJqJdg_G> z%uK%$eC16_`Wx=8)o*Ug<9Km)weN)sqKvU-*BzW$N(8MJEPTiKlGP-CMXAH$y8Am9 z?qlrzTXnl_7TcEN`>V~a7%up0cXz|WGRC)mALsFY<$5vW{kE(Xfe!Bfw)#8AGFkmw z>?@Tecp-BCn+pMd8_xYr`q|dS{^WywyX3cEONII8fBG23+-$g0_ss0PhAH!>KhvIv zENWu3{C~1?Tka~>C;t`ybH)d0EtqnD$(fyQa~n48n`HX7Nr_SQpK{@|bWx^Le_h@^ zG5^T8^uI#wzc&q+*4K;fbuDnXSYAJ2w|ADqPVxD|y8=@j7HJ zn7VeqXN$B}W1UhO>3U(AAtUSEl8!00#!Oy2pG62pv$Kd49-sT;{X4k{|5?AM)iJ(q zIFny4@!a|c+mo8Dk8j2uHc_};J@>?CwLZqmxKoV}Q{ONd?U^XL{r3&lFMq$^2L}GL zGWp#NJo!%!e=q{6c~vsc+>rOgx486;d)x9I&V^gg_$<4I*=YY=!!!C1K-QE$-CynC zAXt9K`N%a%#-%aE3#Qa2GjZ)&sh4qiNy8?+-I3S&5*d%){T^C)`zh;`il6S z^t8dp?zzF?x-RyTU;neNUshTmygy>!JUzy_pHB0AHQ{>kpx0t~VMexU&K6hIms$TcWX zR{?n8KzxS)X8lj`f9NCJ*NnD_2U96eeBkcKG7W>UN0^VsyW3$Q4jCTvYjml;kgj?x z=fX}3X`i+6d!2L(pm44aF$e>MS^qw&9lS80o$KDC&pZH5ymw7*ZhpnH#1m>_d=(yr!jL>W)53q#*l~Uf2}=)D|hooZr>i&sheq zerdlnvVYT79Q5}n9v*88U?2sRHU6EWjW`QV$y1hBd3u)OAiNjSHk<#4FG{S<-Ya-e z<&*(y?pN1KMtx>7;gqw`?qZE`3J$-weBao?U%3<|SonDE@UcBCSbw7QD2Kl)9wqRd zXLCnt9$CTQnTo@C{J^=WEV^-N?u2qZ69%^c!N|4!Luy7W3t}UO zJU#@5tcH+#4^rySY*~Or&NUg$9lE^=l`WCK!y+=GD5UpbXMBwj7X!PH8!_sw_DoQ; z=Gt=7bd3eL7#`Xa^Dw{TVe}4_x>!8L)KJUF34W&u{K&bj&I<$^fr!&Kmk|IB3H*v9rHf8O%Zr8t2isj%$gI2J z{RXX%-B72@u%;AA@fJ$tx1-rroNd{dY!1(c%ig&SlTnpcb{xJlCZq5y-*SXH7$H)! zs@S4#oZ$1ABF=##oYx4L$8h1~y~7A+<*d^j z=`D<~Q%?CuoDlM8DV$sNII&atO*iNE zX`Be>891{Wqi|xaa%MKW%>^gCd3q}L<7qg-QR3@3U5_!sfrpiHo?gHRmQoj&sFqL) zW|SYC6C|RcYYvVRjF=h}dD0fh%+9XOhu8sw<1Ol5e^@*|UOQDaPKz{oDR$J>O~2Gn z_1PS6$h%6wr8x7uPj48r;D?(=R&lLwspKYwezc&@pGGDqsJ|tYU8a#o6ov!wm-Oi5 zuZn-!#Gm8R$x6lS=(v;3bn<~>PNm?mogw)~Vg4y$|4)XbuEJtT0`ILMX{NB=AHOw> zN-kD>R~Z*vMJ0n2c5ei$OlV}ZV*WgVhloa|Di-WYSlC4)a}_8(k{NQuqWX^h8rhj>1nK_n?7F zu2lpm2aX0>z{8tT#hup-AJ9$kuQ()d$

k@z=g+&g`Gp`d~fklqYK57j!=ob&_;7 z&u(ptMV+KeTeuR*dkXX5*_KjC9tAyKmomV_{*F+P9qCK6}!y@UcX|*99PLz zw#iNk^Mt5%Wso-$!TQTo=Tyi2AfZB49Z%ni6N_BUW0-kxUNDud9)C&!J8F~mZj5cw zL)FhQs0~S*qjBL=$cRBNRGcTJke($5rRv&hTbO9|3FvS8V1o1+=4>^aI+aBs+d6*o zn+Wu@M)!sKkW16MC*rk@HYHqSWu>bt|?`PupG>AJD z__|K|jEVH&ITt*p(w|evmru?#O_-ju0JnQhk6zRc+arBOWxp}RX~q~hUS|u(jH`Vh zDZjf=7SR`l%I3;r9DAkC6tXn-ulY4@w%Sa)w&JaNKw=AW$!oE}%`(3`y!gT#{1jqj~1G9b>fS$K|r zH-$oqI<7h=yYsBIQG4WT)Xpdy46T`^hrv%FH=ZZk$*CYO;jSE8Wp4@gQQc;$ln_5{ zj5mCZaUK8B6a2dUsLX6c=^k;&fkN)S(qqzlAe=(x$4VV#KA(Ni`|87)ibMY;L#qh) ziy~TBGQPmV!kfQX^Jt;SJf_z49R~dY;f;ku-VrF_ttw0CF( zSgUIJt#R(;PA%K~_8DQ~N<**-sjZhwkyxu~%24D@E7!ExK+9<7 zu`qFiAy~Lo4alVwlsNMIEN@clxpsCLluPHJM9!UY-eiUa{Hfm6U*xHsZdRC=_k1$+ z1?*$$`y$Ug3b~nI#h=Wuh83k6E5m>0-2abZUt0t~VMexU&K6hIms$TcWX zR{?n8KzxS)X8lj`f9NCJ*NnD_2U96eeBkcKG7W>UN0^VsyW3$Q4jCTvYjml;kgj?x z=fX}3X`i+6d!2L(pm44aF$e>MS^qw&9lS80o$KDC&pZH5ymw7*ZhpnH#1m>_d=(yr!jL>W)53q#*l~Uf2}=)D|hooZr>i&sheq zerdlnvVYT79Q5}n9v*88U?2sRHU6EWjW`QV$y1hBd3u)OAiNjSHk<#4FG{S<-Ya-e z<&*(y?pN1KMtx>7;gqw`?qZE`3J$-weBao?U%3<|SonDE@UcBCSbw7QD2Kl)9wqRd zXLCnt9$CTQnTo@C{J^=WEV^-N?u2qZ69%^c!N|4!Luy7W3t}UO zJU#@5tcH+#4^rySY*~Or&NUg$9lE^=l`WCK!y+=GD5UpbXMBwj7X!PH8!_sw_DoQ; z=Gt=7bd3eL7#`Xa^Dw{TVe}4_x>!8L)KJUF34W&u{K&bj&I<$^fr!&Kmk|IB3H*v9rHf8O%Zr8t2isj%$gI2J z{RXX%-B72@u%;AA@fJ$tx1-rroNd{dY!1(c%ig&SlTnpcb{xJlCZq5y-*SXH7$H)! zs@S4#oZ$1ABF=##oYx4L$8h1~y~7A+<*d^j z=`D<~Q%?CuoDlM8DV$sNII&atO*iNE zX`Be>891{Wqi|xaa%MKW%>^gCd3q}L<7qg-QR3@3U5_!sfrpiHo?gHRmQoj&sFqL) zW|SYC6C|RcYYvVRjF=h}dD0fh%+9XOhu8sw<1Ol5e^@*|UOQDaPKz{oDR$J>O~2Gn z_1PS6$h%6wr8x7uPj48r;D?(=R&lLwspKYwezc&@pGGDqsJ|tYU8a#o6ov!wm-Oi5 zuZn-!#Gm8R$x6lS=(v;3bn<~>PNm?mogw)~Vg4y$|4)XbuEJtT0`ILMX{NB=AHOw> zN-kD>R~Z*vMJ0n2c5ei$OlV}ZV*WgVhloa|Di-WYSlC4)a}_8(k{NQuqWX^h8rhj>1nK_n?7F zu2lpm2aX0>z{8tT#hup-AJ9$kuQ()d$

k@z=g+&g`Gp`d~fklqYK57j!=ob&_;7 z&u(ptMV+KeTeuR*dkXX5*_KjC9tAyKmomV_{*F+P9qCK6}!y@UcX|*99PLz zw#iNk^Mt5%Wso-$!TQTo=Tyi2AfZB49Z%ni6N_BUW0-kxUNDud9)C&!J8F~mZj5cw zL)FhQs0~S*qjBL=$cRBNRGcTJke($5rRv&hTbO9|3FvS8V1o1+=4>^aI+aBs+d6*o zn+Wu@M)!sKkW16MC*rk@HYHqSWu>bt|?`PupG>AJD z__|K|jEVH&ITt*p(w|evmru?#O_-ju0JnQhk6zRc+arBOWxp}RX~q~hUS|u(jH`Vh zDZjf=7SR`l%I3;r9DAkC6tXn-ulY4@w%Sa)w&JaNKw=AW$!oE}%`(3`y!gT#{1jqj~1G9b>fS$K|r zH-$oqI<7h=yYsBIQG4WT)Xpdy46T`^hrv%FH=ZZk$*CYO;jSE8Wp4@gQQc;$ln_5{ zj5mCZaUK8B6a2dUsLX6c=^k;&fkN)S(qqzlAe=(x$4VV#KA(Ni`|87)ibMY;L#qh) ziy~TBGQPmV!kfQX^Jt;SJf_z49R~dY;f;ku-VrF_ttw0CF( zSgUIJt#R(;PA%K~_8DQ~N<**-sjZhwkyxu~%24D@E7!ExK+9<7 zu`qFiAy~Lo4alVwlsNMIEN@clxpsCLluPHJM9!UY-eiUa{Hfm6U*xHsZdRC=_k1$+ z1?*$$`y$Ug3b~nI#h=Wuh83k6E5m>0-2abZUt{{if .Title}}{{.Title}} — {{end}}{{.SiteName}} + + + + + {{block "head" .}}{{end}}